From 6499971bd9de86796c1f91eeea7084ec8b7d9b96 Mon Sep 17 00:00:00 2001 From: Sad Ellie Date: Sun, 7 May 2023 19:19:44 +0300 Subject: [PATCH 01/51] Fix restore formatter settings --- app/src/main/java/com/sadellie/unitto/MainActivity.kt | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/sadellie/unitto/MainActivity.kt b/app/src/main/java/com/sadellie/unitto/MainActivity.kt index 9516e8a5..fd056b32 100644 --- a/app/src/main/java/com/sadellie/unitto/MainActivity.kt +++ b/app/src/main/java/com/sadellie/unitto/MainActivity.kt @@ -27,8 +27,10 @@ import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.core.view.WindowCompat import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.sadellie.unitto.core.ui.Formatter import com.sadellie.unitto.data.userprefs.UserPreferencesRepository import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.flow.onEach import javax.inject.Inject @AndroidEntryPoint @@ -40,8 +42,11 @@ internal class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + val userPrefsFlow = userPrefsRepository.userPreferencesFlow + .onEach { Formatter.setSeparator(it.separator) } + setContent { - val userPrefs = userPrefsRepository.userPreferencesFlow + val userPrefs = userPrefsFlow .collectAsStateWithLifecycle(null).value if (userPrefs != null) UnittoApp(userPrefs) From 8f1847618a7f9d48af9f665f90aed462d7747c56 Mon Sep 17 00:00:00 2001 From: Sad Ellie Date: Sat, 13 May 2023 21:29:25 +0300 Subject: [PATCH 02/51] Tiny fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * New Token-based System™ for input * Unmathematical/Unethical/Cursed percentage support * New Expression Evaluator™ that is not a dumpster fire * Cursor in Converter input text field * Removed garbage code (-200 KB) proper fix for #12 closes #44 closes #52 this is a squashed commit --- app/build.gradle.kts | 2 +- .../com/sadellie/unitto/UnittoNavigation.kt | 3 +- .../com/sadellie/unitto/core/base/Token.kt | 207 +++++----- core/base/src/main/res/values/strings.xml | 1 + .../com/sadellie/unitto/core/ui/Formatter.kt | 251 ------------ .../core/ui/common/textfield/CursorFixer.kt | 73 ++++ .../common/textfield/ExpressionTransformer.kt | 65 +++ .../common/textfield/FormatterExtensions.kt | 193 +++++++++ .../ui/common/textfield/FormatterSymbols.kt | 46 +++ .../ui/common/textfield/InputTextField.kt | 282 +++++++------ .../textfield/TextFieldValueExtensions.kt | 59 +++ .../sadellie/unitto/core/ui/FormatterTest.kt | 232 +++++------ data/common/build.gradle.kts | 1 + .../unitto/data/common/BigDecimalUtils.kt | 4 - .../unitto/data/common/StringUtils.kt | 17 + .../unitto/data/common/IsExpressionText.kt | 49 +++ data/evaluatto/.gitignore | 1 + data/evaluatto/build.gradle.kts | 31 ++ data/evaluatto/consumer-rules.pro | 0 data/evaluatto/src/main/AndroidManifest.xml | 22 ++ .../github/sadellie/evaluatto/Expression.kt | 311 +++++++++++++++ .../io/github/sadellie/evaluatto/Tokenizer.kt | 238 +++++++++++ .../evaluatto/ExpressionComplexTest.kt | 60 +++ .../evaluatto/ExpressionExceptionsTest.kt | 42 ++ .../evaluatto/ExpressionSimpleTest.kt | 111 ++++++ .../sadellie/evaluatto/FixLexiconTest.kt | 122 ++++++ .../io/github/sadellie/evaluatto/Helpers.kt | 46 +++ .../sadellie/evaluatto/TokenizerTest.kt | 50 +++ .../sadellie/unitto/data/licenses/Library.kt | 17 - feature/calculator/build.gradle.kts | 2 +- .../feature/calculator/CalculatorScreen.kt | 79 ++-- .../feature/calculator/CalculatorUIState.kt | 15 +- .../feature/calculator/CalculatorViewModel.kt | 160 +++----- .../feature/calculator/TextFieldController.kt | 225 ----------- .../components/CalculatorKeyboard.kt | 163 ++++---- .../calculator/components/HistoryList.kt | 63 ++- .../calculator/TextFieldControllerTest.kt | 249 ------------ feature/converter/build.gradle.kts | 2 +- .../feature/converter/ConverterScreen.kt | 50 +-- .../feature/converter/ConverterUIState.kt | 19 +- .../feature/converter/ConverterViewModel.kt | 374 ++++-------------- .../feature/converter/components/Keyboard.kt | 81 ++-- .../converter/components/MyTextField.kt | 174 -------- .../feature/converter/components/TopScreen.kt | 167 ++++++-- .../navigation/ConverterNavigation.kt | 2 +- .../converter/ConverterViewModelTest.kt | 255 ------------ .../feature/settings/SettingsViewModel.kt | 3 - .../feature/unitslist/RightSideScreen.kt | 47 ++- .../feature/unitslist/SecondScreenUIState.kt | 4 +- .../feature/unitslist/UnitsListViewModel.kt | 2 + .../navigation/UnitsListNavigation.kt | 4 +- gradle/libs.versions.toml | 4 - settings.gradle.kts | 5 +- 53 files changed, 2529 insertions(+), 2156 deletions(-) delete mode 100644 core/ui/src/main/java/com/sadellie/unitto/core/ui/Formatter.kt create mode 100644 core/ui/src/main/java/com/sadellie/unitto/core/ui/common/textfield/CursorFixer.kt create mode 100644 core/ui/src/main/java/com/sadellie/unitto/core/ui/common/textfield/ExpressionTransformer.kt create mode 100644 core/ui/src/main/java/com/sadellie/unitto/core/ui/common/textfield/FormatterExtensions.kt create mode 100644 core/ui/src/main/java/com/sadellie/unitto/core/ui/common/textfield/FormatterSymbols.kt create mode 100644 core/ui/src/main/java/com/sadellie/unitto/core/ui/common/textfield/TextFieldValueExtensions.kt create mode 100644 data/common/src/test/java/com/sadellie/unitto/data/common/IsExpressionText.kt create mode 100644 data/evaluatto/.gitignore create mode 100644 data/evaluatto/build.gradle.kts create mode 100644 data/evaluatto/consumer-rules.pro create mode 100644 data/evaluatto/src/main/AndroidManifest.xml create mode 100644 data/evaluatto/src/main/java/io/github/sadellie/evaluatto/Expression.kt create mode 100644 data/evaluatto/src/main/java/io/github/sadellie/evaluatto/Tokenizer.kt create mode 100644 data/evaluatto/src/test/java/io/github/sadellie/evaluatto/ExpressionComplexTest.kt create mode 100644 data/evaluatto/src/test/java/io/github/sadellie/evaluatto/ExpressionExceptionsTest.kt create mode 100644 data/evaluatto/src/test/java/io/github/sadellie/evaluatto/ExpressionSimpleTest.kt create mode 100644 data/evaluatto/src/test/java/io/github/sadellie/evaluatto/FixLexiconTest.kt create mode 100644 data/evaluatto/src/test/java/io/github/sadellie/evaluatto/Helpers.kt create mode 100644 data/evaluatto/src/test/java/io/github/sadellie/evaluatto/TokenizerTest.kt delete mode 100644 feature/calculator/src/main/java/com/sadellie/unitto/feature/calculator/TextFieldController.kt delete mode 100644 feature/calculator/src/test/java/com/sadellie/unitto/feature/calculator/TextFieldControllerTest.kt delete mode 100644 feature/converter/src/main/java/com/sadellie/unitto/feature/converter/components/MyTextField.kt delete mode 100644 feature/converter/src/test/java/com/sadellie/unitto/feature/converter/ConverterViewModelTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e4b47310..2b930ffa 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -111,7 +111,7 @@ dependencies { implementation(project(mapOf("path" to ":feature:calculator"))) implementation(project(mapOf("path" to ":feature:settings"))) implementation(project(mapOf("path" to ":feature:unitslist"))) - implementation(project(mapOf("path" to ":feature:epoch"))) + // implementation(project(mapOf("path" to ":feature:epoch"))) implementation(project(mapOf("path" to ":data:units"))) implementation(project(mapOf("path" to ":data:model"))) implementation(project(mapOf("path" to ":data:userprefs"))) diff --git a/app/src/main/java/com/sadellie/unitto/UnittoNavigation.kt b/app/src/main/java/com/sadellie/unitto/UnittoNavigation.kt index 0de72cbd..edb57b7f 100644 --- a/app/src/main/java/com/sadellie/unitto/UnittoNavigation.kt +++ b/app/src/main/java/com/sadellie/unitto/UnittoNavigation.kt @@ -25,7 +25,6 @@ import androidx.navigation.compose.NavHost import com.sadellie.unitto.feature.calculator.navigation.calculatorScreen import com.sadellie.unitto.feature.converter.ConverterViewModel import com.sadellie.unitto.feature.converter.navigation.converterScreen -import com.sadellie.unitto.feature.epoch.navigation.epochScreen import com.sadellie.unitto.feature.settings.SettingsViewModel import com.sadellie.unitto.feature.settings.navigation.navigateToSettings import com.sadellie.unitto.feature.settings.navigation.navigateToUnitGroups @@ -95,6 +94,6 @@ internal fun UnittoNavigation( navigateToSettings = ::navigateToSettings ) - epochScreen(navigateToMenu = openDrawer) + // epochScreen(navigateToMenu = openDrawer) } } diff --git a/core/base/src/main/java/com/sadellie/unitto/core/base/Token.kt b/core/base/src/main/java/com/sadellie/unitto/core/base/Token.kt index 8ea12f19..dd20a0c8 100644 --- a/core/base/src/main/java/com/sadellie/unitto/core/base/Token.kt +++ b/core/base/src/main/java/com/sadellie/unitto/core/base/Token.kt @@ -19,103 +19,126 @@ package com.sadellie.unitto.core.base object Token { - const val _1 = "1" - const val _2 = "2" - const val _3 = "3" - const val _4 = "4" - const val _5 = "5" - const val _6 = "6" - const val _7 = "7" - const val _8 = "8" - const val _9 = "9" - const val _0 = "0" + object Digit { + const val _1 = "1" + const val _2 = "2" + const val _3 = "3" + const val _4 = "4" + const val _5 = "5" + const val _6 = "6" + const val _7 = "7" + const val _8 = "8" + const val _9 = "9" + const val _0 = "0" + const val dot = "." - const val baseA = "A" - const val baseB = "B" - const val baseC = "C" - const val baseD = "D" - const val baseE = "E" - const val baseF = "F" - - const val dot = "." - const val comma = "," - const val E = "E" - - const val plus = "+" - const val minus = "-" - const val minusDisplay = "–" - - const val divide = "/" - const val divideDisplay = "÷" - - const val multiply = "*" - const val multiplyDisplay = "×" - - const val leftBracket = "(" - const val rightBracket = ")" - const val exponent = "^" - const val sqrt = "√" - const val pi = "π" - const val factorial = "!" - const val sin = "sin(" - const val arSin = "arsin(" - const val cos = "cos(" - const val arCos = "arcos(" - const val tan = "tan(" - const val acTan = "actan(" - const val e = "e" - const val exp = "exp(" - const val modulo = "#" - const val ln = "ln(" - const val log = "log(" - const val percent = "%" - - val operators by lazy { - listOf( - plus, - minus, - minusDisplay, - multiply, - multiplyDisplay, - divide, - divideDisplay, - sqrt, - exponent, - ) + val all by lazy { + listOf(_1, _2, _3, _4, _5, _6, _7, _8, _9, _0) + } + val allWithDot by lazy { all + dot } } - val digits by lazy { - listOf( - _1, - _2, - _3, - _4, - _5, - _6, - _7, - _8, - _9, - _0, - ) + object Letter { + const val _A = "A" + const val _B = "B" + const val _C = "C" + const val _D = "D" + const val _E = "E" + const val _F = "F" + + val all by lazy { + listOf(_A, _B, _C, _D, _E, _F) + } } - val internalToDisplay: Map = hashMapOf( - minus to minusDisplay, - multiply to multiplyDisplay, - divide to divideDisplay - ) + object Operator { + const val plus = "+" + const val minus = "−" + const val multiply = "×" + const val divide = "÷" + const val leftBracket = "(" + const val rightBracket = ")" + const val power = "^" + const val factorial = "!" + const val modulo = "#" + const val percent = "%" + const val sqrt = "√" - val knownSymbols: List by lazy { - listOf( - arSin, arCos, acTan, exp, - sin, cos, tan, ln, log, - leftBracket, rightBracket, - exponent, sqrt, factorial, - modulo, e, percent, pi, - multiply, multiplyDisplay, - plus, minus, minusDisplay, divide, divideDisplay, - baseA, baseB, baseC, baseD, baseE, baseF, - _1, _2, _3, _4, _5, _6, _7, _8, _9, _0 - ).sortedByDescending { it.length } + val all by lazy { + listOf( + plus, minus, multiply, divide, + leftBracket, rightBracket, + power, factorial, modulo, percent, sqrt, + ) + } + } + + object Func { + const val sin = "sin" + const val sinBracket = "$sin(" + const val cos = "cos" + const val cosBracket = "$cos(" + const val tan = "tan" + const val tanBracket = "$tan(" + const val arsin = "sin⁻¹" + const val arsinBracket = "$arsin(" + const val arcos = "cos⁻¹" + const val arcosBracket = "$arcos(" + const val actan = "tan⁻¹" + const val actanBracket = "$actan(" + const val ln = "ln" + const val lnBracket = "$ln(" + const val log = "log" + const val logBracket = "$log(" + const val exp = "exp" + const val expBracket = "$exp(" + + val all by lazy { + listOf( + arsin, arcos, actan, sin, cos, tan, log, exp, ln + ).sortedByDescending { it.length } + } + + val allWithOpeningBracket by lazy { + listOf( + arsinBracket, arcosBracket, actanBracket, sinBracket, cosBracket, tanBracket, + logBracket, expBracket, lnBracket + ) + } + } + + object Const { + const val pi = "π" + const val e = "e" + + val all by lazy { + listOf(pi, e) + } + } + + // Used only in formatter, don't use internally + object DisplayOnly { + const val comma = "," + const val engineeringE = "E" + const val minus = "−" + } + + val expressionTokens by lazy { + Digit.allWithDot + Operator.all + Func.all + Const.all + } + + val numberBaseTokens by lazy { + Digit.all + Letter.all + } + + val sexyToUgly by lazy { + mapOf( + Operator.minus to listOf("-", "–", "—", "—"), + Operator.divide to listOf("/"), + Operator.multiply to listOf("*", "•"), + Func.arsin to listOf("arsin"), + Func.arcos to listOf("arcos"), + Func.actan to listOf("actan") + ) } } diff --git a/core/base/src/main/res/values/strings.xml b/core/base/src/main/res/values/strings.xml index b444c12a..ed74eb43 100644 --- a/core/base/src/main/res/values/strings.xml +++ b/core/base/src/main/res/values/strings.xml @@ -1270,6 +1270,7 @@ Clear history All expressions from history will be deleted forever. This action can\'t be undone! No history + Can\'t divide by 0 Number of decimal places 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 deleted file mode 100644 index 4311461b..00000000 --- a/core/ui/src/main/java/com/sadellie/unitto/core/ui/Formatter.kt +++ /dev/null @@ -1,251 +0,0 @@ -/* - * Unitto is a unit converter for Android - * Copyright (c) 2022-2023 Elshan Agaev - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package com.sadellie.unitto.core.ui - -import android.content.Context -import com.sadellie.unitto.core.base.Separator -import com.sadellie.unitto.core.base.Token -import java.math.BigDecimal -import java.math.RoundingMode - -// Legacy, LOL. Will change later -object Formatter : UnittoFormatter() - -open class UnittoFormatter { - /** - * This regex will catch things like "123.456", "123", ".456" - */ - private val numbersRegex = Regex("[\\d.]+") - - private val SPACE = " " - private val PERIOD = "." - private val COMMA = "," - - /** - * Grouping separator. - */ - var grouping: String = SPACE - - /** - * Fractional part separator. - */ - var fractional = Token.comma - - private val timeDivisions by lazy { - mapOf( - R.string.day_short to BigDecimal("86400000000000000000000"), - R.string.hour_short to BigDecimal("3600000000000000000000"), - R.string.minute_short to BigDecimal("60000000000000000000"), - R.string.second_short to BigDecimal("1000000000000000000"), - R.string.millisecond_short to BigDecimal("1000000000000000"), - R.string.microsecond_short to BigDecimal("1000000000000"), - R.string.nanosecond_short to BigDecimal("1000000000"), - R.string.attosecond_short to BigDecimal("1"), - ) - } - - /** - * Change current separator to another [separator]. - * - * @see [Separator] - */ - fun setSeparator(separator: Int) { - grouping = when (separator) { - Separator.PERIOD -> PERIOD - Separator.COMMA -> COMMA - else -> SPACE - } - fractional = if (separator == Separator.PERIOD) Token.comma else Token.dot - } - - /** - * Format [input]. - * - * This will replace operators to their more appealing variants: divide, multiply and minus. - * Plus operator remains unchanged. - * - * Numbers will also be formatted. - * - * @see [formatNumber] - */ - fun format(input: String): String { - // Don't do anything to engineering string. - if (input.contains(Token.E)) return input.replace(Token.dot, fractional) - - var output = input - val allNumbers: List = input.getOnlyNumbers() - - allNumbers.forEach { - output = output.replace(it, formatNumber(it)) - } - - Token.internalToDisplay.forEach { - output = output.replace(it.key, it.value) - } - - return output - } - - /** - * Reapply formatting. Reverses [format] and applies [format] again. - */ - fun reFormat(input: String): String { - // We get 123.45,6789 - // We need 12.345,6789 - - // 123.45,6789 - // Remove grouping - // 12345,6789 - // Replace fractional with "." because formatter accepts only numbers where fractional is a dot - return format( - input - .replace(grouping, "") - .replace(fractional, Token.dot) - ) - } - - /** - * Helper method to change formatting from [input] with a specified [separator] to the one that - * is set for this [UnittoFormatter]. - */ - fun fromSeparator(input: String, separator: Int): String { - val sGrouping = when (separator) { - Separator.PERIOD -> PERIOD - Separator.COMMA -> COMMA - else -> SPACE - } - .also { if (it == grouping) return input } - val sFractional = if (separator == Separator.PERIOD) Token.comma else Token.dot - - return input - .replace(sGrouping, "\t") - .replace(sFractional, fractional) - .replace("\t", grouping) - } - - fun toSeparator(input: String, separator: Int): String { - val output = filterUnknownSymbols(input).replace(fractional, Token.dot) - val sGrouping = when (separator) { - Separator.PERIOD -> PERIOD - Separator.COMMA -> COMMA - else -> SPACE - } - val sFractional = if (separator == Separator.PERIOD) Token.comma else Token.dot - - return format(output) - .replace(grouping, "\t") - .replace(fractional, sFractional) - .replace("\t", sGrouping) - } - - fun removeGrouping(input: String): String = input.replace(grouping, "") - - /** - * Takes [input] and [basicUnit] of the unit to format it to be more human readable. - * - * @return String like "1d 12h 12s". - */ - fun formatTime(context: Context, input: String, basicUnit: BigDecimal?): String { - if (basicUnit == null) return Token._0 - - try { - // Don't need magic if the input is zero - if (BigDecimal(input).compareTo(BigDecimal.ZERO) == 0) return Token._0 - } catch (e: NumberFormatException) { - // For case such as "10-" and "(" - return Token._0 - } - // Attoseconds don't need "magic" - if (basicUnit.compareTo(BigDecimal.ONE) == 0) return formatNumber(input) - - var result = if (input.startsWith(Token.minus)) Token.minus else "" - var remainingSeconds = BigDecimal(input) - .abs() - .multiply(basicUnit) - .setScale(0, RoundingMode.HALF_EVEN) - - if (remainingSeconds.compareTo(BigDecimal.ZERO) == 0) return Token._0 - - timeDivisions.forEach { (timeStr, divider) -> - val division = remainingSeconds.divideAndRemainder(divider) - val time = division.component1() - remainingSeconds = division.component2() - if (time.compareTo(BigDecimal.ZERO) == 1) { - result += "${formatNumber(time.toPlainString())}${context.getString(timeStr)} " - } - } - return result.trimEnd() - } - - /** - * Format given [input]. - * - * Input must be a number with dot!!!. Will replace grouping separators and fractional part (dot) - * separators. - * - * @see grouping - * @see fractional - */ - private fun formatNumber(input: String): String { - if (input.any { it.isLetter() }) return input - - var firstPart = input.takeWhile { it != '.' } - val remainingPart = input.removePrefix(firstPart) - - // Number of empty symbols (spaces) we need to add to correctly split into chunks. - val offset = 3 - firstPart.length.mod(3) - val output = if (offset != 3) { - // We add some spaces at the beginning so that last chunk has 3 symbols - firstPart = " ".repeat(offset) + firstPart - firstPart.chunked(3).joinToString(grouping).drop(offset) - } else { - firstPart.chunked(3).joinToString(grouping) - } - - return (output + remainingPart.replace(".", fractional)) - } - - /** - * @receiver Must be a string with a dot (".") used as a fractional. - */ - private fun String.getOnlyNumbers(): List = - numbersRegex.findAll(this).map(MatchResult::value).toList() - - fun filterUnknownSymbols(input: String): String { - var clearStr = input.replace(" ", "") - var garbage = clearStr - - // String with unknown symbols - Token.knownSymbols.plus(fractional).forEach { - garbage = garbage.replace(it, " ") - } - - // Remove unknown symbols from input - garbage.split(" ").forEach { - clearStr = clearStr.replace(it, "") - } - - clearStr = clearStr - .replace(Token.divide, Token.divideDisplay) - .replace(Token.multiply, Token.multiplyDisplay) - .replace(Token.minus, Token.minusDisplay) - - return clearStr - } -} diff --git a/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/textfield/CursorFixer.kt b/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/textfield/CursorFixer.kt new file mode 100644 index 00000000..723cbb6b --- /dev/null +++ b/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/textfield/CursorFixer.kt @@ -0,0 +1,73 @@ +/* + * Unitto is a unit converter for Android + * Copyright (c) 2023 Elshan Agaev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.sadellie.unitto.core.ui.common.textfield + +import com.sadellie.unitto.core.base.Token +import kotlin.math.abs + +fun String.fixCursor(pos: Int, grouping: String): Int { + + if (isEmpty()) return pos + + // Best position if we move cursor left + var leftCursor = pos + while (this.isPlacedIllegallyAt(leftCursor, grouping)) leftCursor-- + + // Best position if we move cursor right + var rightCursor = pos + while (this.isPlacedIllegallyAt(rightCursor, grouping)) rightCursor++ + + return listOf(leftCursor, rightCursor).minBy { abs(it - pos) } +} + +fun String.tokenLengthAhead(pos: Int): Int { + Token.Func.allWithOpeningBracket.forEach { + if (pos.isAfterToken(this, it)) return it.length + } + + return 1 +} + +private fun String.isPlacedIllegallyAt(pos: Int, grouping: String): Boolean { + // For things like "123,|456" - this is illegal + if (pos.isAfterToken(this, grouping)) return true + + // For things like "123,456+c|os(8)" - this is illegal + Token.Func.allWithOpeningBracket.forEach { + if (pos.isAtToken(this, it)) return true + } + + return false +} + +private fun Int.isAtToken(str: String, token: String): Boolean { + val checkBound = (token.length - 1).coerceAtLeast(1) + return str + .substring( + startIndex = (this - checkBound).coerceAtLeast(0), + endIndex = (this + checkBound).coerceAtMost(str.length) + ) + .contains(token) +} + +private fun Int.isAfterToken(str: String, token: String): Boolean { + return str + .substring((this - token.length).coerceAtLeast(0), this) + .contains(token) +} diff --git a/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/textfield/ExpressionTransformer.kt b/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/textfield/ExpressionTransformer.kt new file mode 100644 index 00000000..eb59a8c6 --- /dev/null +++ b/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/textfield/ExpressionTransformer.kt @@ -0,0 +1,65 @@ +/* + * Unitto is a unit converter for Android + * Copyright (c) 2023 Elshan Agaev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.sadellie.unitto.core.ui.common.textfield + +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.input.OffsetMapping +import androidx.compose.ui.text.input.TransformedText +import androidx.compose.ui.text.input.VisualTransformation + +class ExpressionTransformer(private val formatterSymbols: FormatterSymbols) : VisualTransformation { + + override fun filter(text: AnnotatedString): TransformedText { + val formatted = text.text.formatExpression(formatterSymbols) + return TransformedText( + text = AnnotatedString(formatted), + offsetMapping = ExpressionMapping(text.text, formatted) + ) + } + + inner class ExpressionMapping( + private val unformatted: String, + private val formatted: String + ) : OffsetMapping { + + // Called when entering text (on each text change) + // Basically moves cursor to the right position + // + // original input is "1000" and cursor is placed at the end "1000|" + // the formatted is "1,000" where cursor should be? - "1,000|" + override fun originalToTransformed(offset: Int): Int { + val fixedCursor = unformatted.fixCursor(offset, formatterSymbols.grouping) + return fixedCursor + countAddedSymbolsBeforeCursor(fixedCursor) + } + + // Called when clicking formatted text + // Snaps cursor to the right position + // + // the formatted is "1,000" and cursor is placed at the end "1,000|" + // original input is "1000" where cursor should be? - "1000|" + override fun transformedToOriginal(offset: Int): Int { + val fixedCursor = formatted.fixCursor(offset, formatterSymbols.grouping) + return fixedCursor - countAddedSymbolsBeforeCursor(fixedCursor) + } + + private fun countAddedSymbolsBeforeCursor(cursor: Int): Int { + return formatted.take(cursor).count { it.toString() == formatterSymbols.grouping } + } + } +} diff --git a/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/textfield/FormatterExtensions.kt b/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/textfield/FormatterExtensions.kt new file mode 100644 index 00000000..c80d3529 --- /dev/null +++ b/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/textfield/FormatterExtensions.kt @@ -0,0 +1,193 @@ +/* + * Unitto is a unit converter for Android + * Copyright (c) 2023 Elshan Agaev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.sadellie.unitto.core.ui.common.textfield + +import android.content.Context +import com.sadellie.unitto.core.base.Token +import com.sadellie.unitto.core.ui.R +import java.math.BigDecimal +import java.math.RoundingMode + +private val numbersRegex by lazy { Regex("[\\d.]+") } + +private val timeDivisions by lazy { + mapOf( + R.string.day_short to BigDecimal("86400000000000000000000"), + R.string.hour_short to BigDecimal("3600000000000000000000"), + R.string.minute_short to BigDecimal("60000000000000000000"), + R.string.second_short to BigDecimal("1000000000000000000"), + R.string.millisecond_short to BigDecimal("1000000000000000"), + R.string.microsecond_short to BigDecimal("1000000000000"), + R.string.nanosecond_short to BigDecimal("1000000000"), + R.string.attosecond_short to BigDecimal("1"), + ) +} + +internal fun String.clearAndFilterExpression(formatterSymbols: FormatterSymbols): String { + var clean = this + .replace(formatterSymbols.grouping, "") + .replace(formatterSymbols.fractional, Token.Digit.dot) + .replace(" ", "") + + Token.sexyToUgly.forEach { (token, ugliness) -> + ugliness.forEach { + clean = clean.replace(it, token) + } + } + + return clean.cleanIt(Token.expressionTokens) +} + +internal fun String.clearAndFilterNumberBase(): String { + return uppercase().cleanIt(Token.numberBaseTokens) +} + +/** + * Format string time conversion result into a more readable format. + * + * @param basicUnit Basic unit of the unit we convert to + * @return String like "1d 12h 12s". + */ +fun String.formatTime( + context: Context, + basicUnit: BigDecimal?, + formatterSymbols: FormatterSymbols +): String { + // We get ugly version of input (non-fancy minus) + val input = this + + if (basicUnit == null) return Token.Digit._0 + + try { + // Don't need magic if the input is zero + if (BigDecimal(input).compareTo(BigDecimal.ZERO) == 0) return Token.Digit._0 + } catch (e: NumberFormatException) { + // For case such as "10-" and "(" + return Token.Digit._0 + } + // Attoseconds don't need "magic" + if (basicUnit.compareTo(BigDecimal.ONE) == 0) return input.formatExpression(formatterSymbols) + + var result = if (input.startsWith("-")) Token.Operator.minus else "" + var remainingSeconds = BigDecimal(input) + .abs() + .multiply(basicUnit) + .setScale(0, RoundingMode.HALF_EVEN) + + if (remainingSeconds.compareTo(BigDecimal.ZERO) == 0) return Token.Digit._0 + + timeDivisions.forEach { (timeStr, divider) -> + val division = remainingSeconds.divideAndRemainder(divider) + val time = division.component1() + remainingSeconds = division.component2() + if (time.compareTo(BigDecimal.ZERO) != 0) { + result += "${time.toPlainString().formatExpression(formatterSymbols)}${context.getString(timeStr)} " + } + } + return result.trimEnd() +} + +fun String.formatExpression( + formatterSymbols: FormatterSymbols +): String { + var input = this + // Don't do anything to engineering string. + if (input.contains(Token.DisplayOnly.engineeringE)) { + return input.replace(Token.Digit.dot, formatterSymbols.fractional) + } + + numbersRegex + .findAll(input) + .map(MatchResult::value) + .forEach { + input = input.replace(it, it.formatNumber(formatterSymbols)) + } + + Token.sexyToUgly.forEach { (token, ugliness) -> + ugliness.forEach { uglySymbol -> + input = input.replace(uglySymbol, token) + } + } + + return input +} + +private fun String.formatNumber( + formatterSymbols: FormatterSymbols +): String { + val input = this + + if (input.any { it.isLetter() }) return input + + var firstPart = input.takeWhile { it != '.' } + val remainingPart = input.removePrefix(firstPart) + + // Number of empty symbols (spaces) we need to add to correctly split into chunks. + val offset = 3 - firstPart.length.mod(3) + val output = if (offset != 3) { + // We add some spaces at the beginning so that last chunk has 3 symbols + firstPart = " ".repeat(offset) + firstPart + firstPart.chunked(3).joinToString(formatterSymbols.grouping).drop(offset) + } else { + firstPart.chunked(3).joinToString(formatterSymbols.grouping) + } + + return output.plus(remainingPart.replace(".", formatterSymbols.fractional)) +} + +private fun String.cleanIt(legalTokens: List): String { + val streamOfTokens = this + + fun peekTokenAfter(cursor: Int): String? { + legalTokens.forEach { token -> + val subs = streamOfTokens + .substring( + cursor, + (cursor + token.length).coerceAtMost(streamOfTokens.length) + ) + if (subs == token) { + // Got a digit, see if there are other digits coming after + if (token in Token.Digit.allWithDot) { + return streamOfTokens + .substring(cursor) + .takeWhile { Token.Digit.allWithDot.contains(it.toString()) } + } + return token + } + } + return null + } + + var cursor = 0 + var tokens = "" + + while (cursor != streamOfTokens.length) { + val nextToken = peekTokenAfter(cursor) + + if (nextToken != null) { + tokens += nextToken + cursor += nextToken.length + } else { + // Didn't find any token, move left slowly (by 1 symbol) + cursor++ + } + } + + return tokens +} diff --git a/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/textfield/FormatterSymbols.kt b/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/textfield/FormatterSymbols.kt new file mode 100644 index 00000000..e8509dad --- /dev/null +++ b/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/textfield/FormatterSymbols.kt @@ -0,0 +1,46 @@ +/* + * Unitto is a unit converter for Android + * Copyright (c) 2023 Elshan Agaev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.sadellie.unitto.core.ui.common.textfield + +import com.sadellie.unitto.core.base.Separator + +sealed class FormatterSymbols(val grouping: String, val fractional: String) { + object Spaces : FormatterSymbols(" ", ".") + object Period : FormatterSymbols(".", ",") + object Comma : FormatterSymbols(",", ".") +} + +object AllFormatterSymbols { + private val allFormatterSymbs by lazy { + hashMapOf( + Separator.SPACES to FormatterSymbols.Spaces, + Separator.PERIOD to FormatterSymbols.Period, + Separator.COMMA to FormatterSymbols.Comma + ) + } + + /** + * Defaults to [FormatterSymbols.Spaces] if not found. + * + * @see Separator + */ + fun getById(separator: Int): FormatterSymbols { + return allFormatterSymbs.getOrElse(separator) { FormatterSymbols.Spaces } + } +} diff --git a/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/textfield/InputTextField.kt b/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/textfield/InputTextField.kt index 7ef702a6..c91470b7 100644 --- a/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/textfield/InputTextField.kt +++ b/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/textfield/InputTextField.kt @@ -24,6 +24,7 @@ import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.text.BasicTextField import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue @@ -54,105 +55,124 @@ import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.createFontFamilyResolver import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.sp -import com.sadellie.unitto.core.base.Separator -import com.sadellie.unitto.core.ui.Formatter import com.sadellie.unitto.core.ui.theme.NumbersTextStyleDisplayLarge import kotlin.math.ceil import kotlin.math.roundToInt @Composable -fun InputTextField( +fun ExpressionTextField( modifier: Modifier, value: TextFieldValue, - textStyle: TextStyle = NumbersTextStyleDisplayLarge, minRatio: Float = 1f, - cutCallback: () -> Unit, - pasteCallback: (String) -> Unit, - onCursorChange: (IntRange) -> Unit, + cutCallback: () -> Unit = {}, + pasteCallback: (String) -> Unit = {}, + onCursorChange: (TextRange) -> Unit, textColor: Color = MaterialTheme.colorScheme.onSurfaceVariant, + formatterSymbols: FormatterSymbols, + readOnly: Boolean = false, + placeholder: String? = null, ) { - val clipboardManager = LocalClipboardManager.current - fun copyCallback() = clipboardManager.copyWithoutGrouping(value) - - val textToolbar = UnittoTextToolbar( - view = LocalView.current, - copyCallback = ::copyCallback, - pasteCallback = { - pasteCallback( - Formatter.toSeparator( - clipboardManager.getText()?.text ?: "", Separator.COMMA - ) - ) - }, - cutCallback = { - copyCallback() - cutCallback() - onCursorChange(value.selection.end..value.selection.end) - } - ) - - CompositionLocalProvider( - LocalTextInputService provides null, - LocalTextToolbar provides textToolbar - ) { - AutoSizableTextField( - modifier = modifier, - value = value, - textStyle = textStyle.copy(color = textColor), - minRatio = minRatio, - onValueChange = { - onCursorChange(it.selection.start..it.selection.end) - }, - showToolbar = textToolbar::showMenu, - hideToolbar = textToolbar::hide - ) - } -} - -@Composable -fun InputTextField( - modifier: Modifier = Modifier, - value: String, - textStyle: TextStyle = NumbersTextStyleDisplayLarge, - minRatio: Float = 1f, - textColor: Color = MaterialTheme.colorScheme.onSurfaceVariant, - interactionSource: MutableInteractionSource = remember { MutableInteractionSource() } -) { - var textFieldValue by remember(value) { - mutableStateOf(TextFieldValue(value, selection = TextRange(value.length))) - } val clipboardManager = LocalClipboardManager.current fun copyCallback() { - clipboardManager.copyWithoutGrouping(textFieldValue) - textFieldValue = textFieldValue.copy(selection = TextRange(textFieldValue.selection.end)) + clipboardManager.copyWithoutGrouping(value, formatterSymbols) + onCursorChange(TextRange(value.selection.end)) } - CompositionLocalProvider( - LocalTextInputService provides null, - LocalTextToolbar provides UnittoTextToolbar( + val textToolbar: UnittoTextToolbar = if (readOnly) { + UnittoTextToolbar( view = LocalView.current, copyCallback = ::copyCallback, ) - ) { - AutoSizableTextField( - modifier = modifier, - value = textFieldValue, - onValueChange = { textFieldValue = it }, - textStyle = textStyle.copy(color = textColor), - minRatio = minRatio, - readOnly = true, - interactionSource = interactionSource + } else { + UnittoTextToolbar( + view = LocalView.current, + copyCallback = ::copyCallback, + pasteCallback = { + pasteCallback(clipboardManager.getText()?.text?.clearAndFilterExpression(formatterSymbols) ?: "") + }, + cutCallback = { + clipboardManager.copyWithoutGrouping(value, formatterSymbols) + cutCallback() + } ) } + + AutoSizableTextField( + modifier = modifier, + value = value, + formattedValue = value.text.formatExpression(formatterSymbols), + textStyle = NumbersTextStyleDisplayLarge.copy(color = textColor), + minRatio = minRatio, + onValueChange = { onCursorChange(it.selection) }, + readOnly = readOnly, + showToolbar = textToolbar::showMenu, + hideToolbar = textToolbar::hide, + visualTransformation = ExpressionTransformer(formatterSymbols), + placeholder = placeholder, + textToolbar = textToolbar + ) +} + +@Composable +fun UnformattedTextField( + modifier: Modifier, + value: TextFieldValue, + minRatio: Float = 1f, + cutCallback: () -> Unit = {}, + pasteCallback: (String) -> Unit = {}, + onCursorChange: (TextRange) -> Unit, + textColor: Color = MaterialTheme.colorScheme.onSurfaceVariant, + readOnly: Boolean = false, + placeholder: String? = null, +) { + val clipboardManager = LocalClipboardManager.current + fun copyCallback() { + clipboardManager.copy(value) + onCursorChange(TextRange(value.selection.end)) + } + + val textToolbar: UnittoTextToolbar = if (readOnly) { + UnittoTextToolbar( + view = LocalView.current, + copyCallback = ::copyCallback, + ) + } else { + UnittoTextToolbar( + view = LocalView.current, + copyCallback = ::copyCallback, + pasteCallback = { + pasteCallback(clipboardManager.getText()?.text?.clearAndFilterNumberBase() ?: "") + }, + cutCallback = { + clipboardManager.copy(value) + cutCallback() + } + ) + } + + AutoSizableTextField( + modifier = modifier, + value = value, + textStyle = NumbersTextStyleDisplayLarge.copy(color = textColor), + minRatio = minRatio, + onValueChange = { onCursorChange(it.selection) }, + readOnly = readOnly, + showToolbar = textToolbar::showMenu, + hideToolbar = textToolbar::hide, + placeholder = placeholder, + textToolbar = textToolbar + ) } @Composable private fun AutoSizableTextField( modifier: Modifier = Modifier, value: TextFieldValue, + formattedValue: String = value.text, textStyle: TextStyle = TextStyle(), scaleFactor: Float = 0.95f, minRatio: Float = 1f, @@ -160,11 +180,14 @@ private fun AutoSizableTextField( readOnly: Boolean = false, showToolbar: (rect: Rect) -> Unit = {}, hideToolbar: () -> Unit = {}, - interactionSource: MutableInteractionSource = remember { MutableInteractionSource() } + visualTransformation: VisualTransformation = VisualTransformation.None, + placeholder: String? = null, + textToolbar: UnittoTextToolbar ) { val focusRequester = remember { FocusRequester() } val density = LocalDensity.current + val textValue = value.copy(value.text.take(2000)) var nFontSize: TextUnit by remember { mutableStateOf(0.sp) } var minFontSize: TextUnit @@ -181,7 +204,7 @@ private fun AutoSizableTextField( // Modified: https://blog.canopas.com/autosizing-textfield-in-jetpack-compose-7a80f0270853 val calculateParagraph = @Composable { Paragraph( - text = value.text, + text = formattedValue, style = textStyle.copy(fontSize = nFontSize), constraints = Constraints( maxWidth = ceil(with(density) { maxWidth.toPx() }).toInt() @@ -210,45 +233,70 @@ private fun AutoSizableTextField( ) var offset = Offset.Zero - BasicTextField( - value = value, - onValueChange = { - showToolbar(Rect(offset, 0f)) - hideToolbar() - onValueChange(it) - }, - modifier = Modifier - .focusRequester(focusRequester) - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null, - onClick = { - hideToolbar() - focusRequester.requestFocus() - onValueChange(value.copy(selection = TextRange.Zero)) - showToolbar(Rect(offset, 0f)) + CompositionLocalProvider( + LocalTextInputService provides null, + LocalTextToolbar provides textToolbar + ) { + BasicTextField( + value = textValue, + onValueChange = { + showToolbar(Rect(offset, 0f)) + hideToolbar() + onValueChange(it) + }, + modifier = Modifier + .focusRequester(focusRequester) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = { + hideToolbar() + focusRequester.requestFocus() + onValueChange(value.copy(selection = TextRange.Zero)) + showToolbar(Rect(offset, 0f)) + } + ) + .widthIn(max = with(density) { intrinsics.width.toDp() }) + .layout { measurable, constraints -> + val placeable = measurable.measure(constraints) + // TextField size is changed with a delay (text jumps). Here we correct it. + layout(placeable.width, placeable.height) { + placeable.place( + x = (intrinsics.width - intrinsics.maxIntrinsicWidth) + .coerceAtLeast(0f) + .roundToInt(), + y = (placeable.height - intrinsics.height).roundToInt() + ) + } } - ) - .widthIn(max = with(density) { intrinsics.width.toDp() }) - .layout { measurable, constraints -> - val placeable = measurable.measure(constraints) - // TextField size is changed with a delay (text jumps). Here we correct it. - layout(placeable.width, placeable.height) { - placeable.place( - x = (intrinsics.width - intrinsics.maxIntrinsicWidth) - .coerceAtLeast(0f) - .roundToInt(), - y = (placeable.height - intrinsics.height).roundToInt() + .onGloballyPositioned { layoutCoords -> + offset = layoutCoords.positionInWindow() + }, + textStyle = nTextStyle, + cursorBrush = SolidColor(MaterialTheme.colorScheme.onSurfaceVariant), + singleLine = true, + readOnly = readOnly, + visualTransformation = visualTransformation, + decorationBox = { innerTextField -> + if (textValue.text.isEmpty() and !placeholder.isNullOrEmpty()) { + Text( + text = placeholder!!, // It's not null, i swear + style = nTextStyle, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f), + modifier = Modifier.layout { measurable, constraints -> + val placeable = measurable.measure(constraints) + + layout(placeable.width, placeable.height) { + placeable.place(x = -placeable.width, y = 0) + } + } ) } + + innerTextField() } - .onGloballyPositioned { layoutCoords -> offset = layoutCoords.positionInWindow() }, - textStyle = nTextStyle, - cursorBrush = SolidColor(MaterialTheme.colorScheme.onSurfaceVariant), - singleLine = true, - readOnly = readOnly, - interactionSource = interactionSource - ) + ) + } } } @@ -260,8 +308,22 @@ private fun AutoSizableTextField( * * @param value Formatted value that has grouping symbols. */ -fun ClipboardManager.copyWithoutGrouping(value: TextFieldValue) = this.setText( +fun ClipboardManager.copyWithoutGrouping( + value: TextFieldValue, + formatterSymbols: FormatterSymbols +) = this.setText( AnnotatedString( - Formatter.removeGrouping(value.annotatedString.subSequence(value.selection).text) + value.annotatedString + .subSequence(value.selection) + .text + .replace(formatterSymbols.grouping, "") + ) +) + +fun ClipboardManager.copy(value: TextFieldValue) = this.setText( + AnnotatedString( + value.annotatedString + .subSequence(value.selection) + .text ) ) diff --git a/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/textfield/TextFieldValueExtensions.kt b/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/textfield/TextFieldValueExtensions.kt new file mode 100644 index 00000000..5e48fd28 --- /dev/null +++ b/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/textfield/TextFieldValueExtensions.kt @@ -0,0 +1,59 @@ +/* + * Unitto is a unit converter for Android + * Copyright (c) 2023 Elshan Agaev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.sadellie.unitto.core.ui.common.textfield + +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue + +fun TextFieldValue.addTokens(tokens: String): TextFieldValue { + return this.copy( + text = text.replaceRange(selection.start, selection.end, tokens), + selection = TextRange(selection.start + tokens.length) + ) +} + +fun TextFieldValue.deleteTokens(): TextFieldValue { + val distanceFromEnd = text.length - selection.end + + val deleteRangeStart = when (selection.end) { + // Don't delete if at the start of the text field + 0 -> return this + // We don't have anything selected (cursor in one position) + // like this 1234|56 => after deleting will be like this 123|56 + // Cursor moved one symbol left + selection.start -> { + // We default to 1 here. It means that cursor is not placed after illegal token + // Just a number or a binary operator or something else, can delete by one symbol + val symbolsToDelete = text.tokenLengthAhead(selection.end) + + selection.start - symbolsToDelete + } + // We have multiple symbols selected + // like this 123[45]6 => after deleting will be like this 123|6 + // Cursor will be placed where selection start was + else -> selection.start + } + + val newText = text.removeRange(deleteRangeStart, selection.end) + + return this.copy( + text = newText, + selection = TextRange((newText.length - distanceFromEnd).coerceAtLeast(0)) + ) +} 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 cf8cc458..28c87f7c 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 @@ -20,7 +20,9 @@ package com.sadellie.unitto.core.ui import android.content.Context import androidx.compose.ui.test.junit4.createComposeRule -import com.sadellie.unitto.core.base.Separator +import com.sadellie.unitto.core.ui.common.textfield.FormatterSymbols +import com.sadellie.unitto.core.ui.common.textfield.formatExpression +import com.sadellie.unitto.core.ui.common.textfield.formatTime import org.junit.Assert.assertEquals import org.junit.Rule import org.junit.Test @@ -29,16 +31,14 @@ import org.robolectric.RobolectricTestRunner import org.robolectric.RuntimeEnvironment import java.math.BigDecimal -private val formatter = Formatter - private const val ENG_VALUE = "123E+21" private const val ENG_VALUE_FRACTIONAL = "123.3E+21" private const val COMPLETE_VALUE = "123456.789" private const val INCOMPLETE_VALUE = "123456." private const val NO_FRACTIONAL_VALUE = "123456" -private const val INCOMPLETE_EXPR = "50+123456÷8×0.8–12+" -private const val COMPLETE_EXPR = "50+123456÷8×0.8–12+0-√9*4^9+2×(9+8×7)" -private const val LONG_HALF_COMPLETE_EXPR = "50+123456÷89078..9×0.8–12+0-√9*4^9+2×(9+8×7)×sin(13sin123cos" +private const val INCOMPLETE_EXPR = "50+123456÷8×0.8-12+" +private const val COMPLETE_EXPR = "50+123456÷8×0.8-12+0-√9×4^9+2×(9+8×7)" +private const val LONG_HALF_COMPLETE_EXPR = "50+123456÷89078..9×0.8-12+0-√9×4^9+2×(9+8×7)×sin(13sin123cos" private const val SOME_BRACKETS = "((((((((" @RunWith(RobolectricTestRunner::class) @@ -49,167 +49,143 @@ class FormatterTest { @Test fun setSeparatorSpaces() { - formatter.setSeparator(Separator.SPACES) - assertEquals(".", formatter.fractional) - assertEquals("123E+21", formatter.format(ENG_VALUE)) - assertEquals("123.3E+21", formatter.format(ENG_VALUE_FRACTIONAL)) - assertEquals("123 456.789", formatter.format(COMPLETE_VALUE)) - assertEquals("123 456.", formatter.format(INCOMPLETE_VALUE)) - assertEquals("123 456", formatter.format(NO_FRACTIONAL_VALUE)) - assertEquals("50+123 456÷8×0.8–12+", formatter.format(INCOMPLETE_EXPR)) - assertEquals("50+123 456÷8×0.8–12+0–√9×4^9+2×(9+8×7)", formatter.format(COMPLETE_EXPR)) - assertEquals("50+123 456÷89 078..9×0.8–12+0–√9×4^9+2×(9+8×7)×sin(13sin123cos", formatter.format(LONG_HALF_COMPLETE_EXPR)) - assertEquals("((((((((", formatter.format(SOME_BRACKETS)) + fun String.format(): String = formatExpression(FormatterSymbols.Spaces) + assertEquals("123E+21", ENG_VALUE.format()) + assertEquals("123.3E+21", ENG_VALUE_FRACTIONAL.format()) + assertEquals("123 456.789", COMPLETE_VALUE.format()) + assertEquals("123 456.", INCOMPLETE_VALUE.format()) + assertEquals("123 456", NO_FRACTIONAL_VALUE.format()) + assertEquals("50+123 456÷8×0.8−12+", INCOMPLETE_EXPR.format()) + assertEquals("50+123 456÷8×0.8−12+0−√9×4^9+2×(9+8×7)", COMPLETE_EXPR.format()) + assertEquals("50+123 456÷89 078..9×0.8−12+0−√9×4^9+2×(9+8×7)×sin(13sin123cos", LONG_HALF_COMPLETE_EXPR.format()) + assertEquals("((((((((", SOME_BRACKETS.format()) } @Test fun setSeparatorComma() { - formatter.setSeparator(Separator.COMMA) - assertEquals(".", formatter.fractional) - assertEquals("123E+21", formatter.format(ENG_VALUE)) - assertEquals("123.3E+21", formatter.format(ENG_VALUE_FRACTIONAL)) - assertEquals("123,456.789", formatter.format(COMPLETE_VALUE)) - assertEquals("123,456.", formatter.format(INCOMPLETE_VALUE)) - assertEquals("123,456", formatter.format(NO_FRACTIONAL_VALUE)) - assertEquals("50+123,456÷8×0.8–12+", formatter.format(INCOMPLETE_EXPR)) - assertEquals("50+123,456÷8×0.8–12+0–√9×4^9+2×(9+8×7)", formatter.format(COMPLETE_EXPR)) - assertEquals("50+123,456÷89,078..9×0.8–12+0–√9×4^9+2×(9+8×7)×sin(13sin123cos", formatter.format(LONG_HALF_COMPLETE_EXPR)) - assertEquals("((((((((", formatter.format(SOME_BRACKETS)) + fun String.format(): String = formatExpression(FormatterSymbols.Comma) + assertEquals("123E+21", ENG_VALUE.format()) + assertEquals("123.3E+21", ENG_VALUE_FRACTIONAL.format()) + assertEquals("123,456.789", COMPLETE_VALUE.format()) + assertEquals("123,456.", INCOMPLETE_VALUE.format()) + assertEquals("123,456", NO_FRACTIONAL_VALUE.format()) + assertEquals("50+123,456÷8×0.8−12+", INCOMPLETE_EXPR.format()) + assertEquals("50+123,456÷8×0.8−12+0−√9×4^9+2×(9+8×7)", COMPLETE_EXPR.format()) + assertEquals("50+123,456÷89,078..9×0.8−12+0−√9×4^9+2×(9+8×7)×sin(13sin123cos", LONG_HALF_COMPLETE_EXPR.format()) + assertEquals("((((((((", SOME_BRACKETS.format()) } @Test fun setSeparatorPeriod() { - formatter.setSeparator(Separator.PERIOD) - assertEquals(",", formatter.fractional) - assertEquals("123E+21", formatter.format(ENG_VALUE)) - assertEquals("123,3E+21", formatter.format(ENG_VALUE_FRACTIONAL)) - assertEquals("123.456,789", formatter.format(COMPLETE_VALUE)) - assertEquals("123.456,", formatter.format(INCOMPLETE_VALUE)) - assertEquals("123.456", formatter.format(NO_FRACTIONAL_VALUE)) - assertEquals("50+123.456÷8×0,8–12+", formatter.format(INCOMPLETE_EXPR)) - assertEquals("50+123.456÷8×0,8–12+0–√9×4^9+2×(9+8×7)", formatter.format(COMPLETE_EXPR)) - assertEquals("50+123.456÷89.078,,9×0,8–12+0–√9×4^9+2×(9+8×7)×sin(13sin123cos", formatter.format(LONG_HALF_COMPLETE_EXPR)) - assertEquals("((((((((", formatter.format(SOME_BRACKETS)) + fun String.format(): String = formatExpression(FormatterSymbols.Period) + assertEquals("123E+21", ENG_VALUE.format()) + assertEquals("123,3E+21", ENG_VALUE_FRACTIONAL.format()) + assertEquals("123.456,789", COMPLETE_VALUE.format()) + assertEquals("123.456,", INCOMPLETE_VALUE.format()) + assertEquals("123.456", NO_FRACTIONAL_VALUE.format()) + assertEquals("50+123.456÷8×0,8−12+", INCOMPLETE_EXPR.format()) + assertEquals("50+123.456÷8×0,8−12+0−√9×4^9+2×(9+8×7)", COMPLETE_EXPR.format()) + assertEquals("50+123.456÷89.078,,9×0,8−12+0−√9×4^9+2×(9+8×7)×sin(13sin123cos", LONG_HALF_COMPLETE_EXPR.format()) + assertEquals("((((((((", SOME_BRACKETS.format()) } @Test fun formatTimeTest() { - formatter.setSeparator(Separator.SPACES) + val formatterSymbols = FormatterSymbols.Spaces var basicValue = BigDecimal.valueOf(1) val mContext: Context = RuntimeEnvironment.getApplication().applicationContext - assertEquals("-28", formatter.formatTime(mContext, "-28", basicValue)) - assertEquals("-0.05", formatter.formatTime(mContext, "-0.05", basicValue)) - assertEquals("0", formatter.formatTime(mContext, "0", basicValue)) - assertEquals("0", formatter.formatTime(mContext, "-0", basicValue)) + + fun String.formatTime() = this.formatTime(mContext, basicValue, formatterSymbols) + + assertEquals("−28", "-28".formatTime()) + assertEquals("−0.05", "-0.05".formatTime()) + assertEquals("0", "0".formatTime()) + assertEquals("0", "−0".formatTime()) basicValue = BigDecimal.valueOf(86_400_000_000_000_000_000_000.0) - assertEquals("-28d", formatter.formatTime(mContext, "-28", basicValue)) - assertEquals("-1h 12m", formatter.formatTime(mContext, "-0.05", basicValue)) - assertEquals("0", formatter.formatTime(mContext, "0", basicValue)) - assertEquals("0", formatter.formatTime(mContext, "-0", basicValue)) + assertEquals("−28d", "-28".formatTime()) + assertEquals("−1h 12m", "-0.05".formatTime()) + assertEquals("0", "0".formatTime()) + assertEquals("0", "-0".formatTime()) // DAYS basicValue = BigDecimal.valueOf(86_400_000_000_000_000_000_000.0) - assertEquals("12h", formatter.formatTime(mContext, "0.5", basicValue)) - assertEquals("1h 12m", formatter.formatTime(mContext, "0.05", basicValue)) - assertEquals("7m 12s", formatter.formatTime(mContext, "0.005", basicValue)) - assertEquals("28d", formatter.formatTime(mContext, "28", basicValue)) - assertEquals("90d", formatter.formatTime(mContext, "90", basicValue)) - assertEquals("90d 12h", formatter.formatTime(mContext, "90.5", basicValue)) - assertEquals("90d 7m 12s", formatter.formatTime(mContext, "90.005", basicValue)) + assertEquals("12h","0.5".formatTime()) + assertEquals("1h 12m","0.05".formatTime()) + assertEquals("7m 12s","0.005".formatTime()) + assertEquals("28d","28".formatTime()) + assertEquals("90d","90".formatTime()) + assertEquals("90d 12h","90.5".formatTime()) + assertEquals("90d 7m 12s","90.005".formatTime()) // HOURS basicValue = BigDecimal.valueOf(3_600_000_000_000_000_000_000.0) - assertEquals("30m", formatter.formatTime(mContext, "0.5", basicValue)) - assertEquals("3m", formatter.formatTime(mContext, "0.05", basicValue)) - assertEquals("18s", formatter.formatTime(mContext, "0.005", basicValue)) - assertEquals("1d 4h", formatter.formatTime(mContext, "28", basicValue)) - assertEquals("3d 18h", formatter.formatTime(mContext, "90", basicValue)) - assertEquals("3d 18h 30m", formatter.formatTime(mContext, "90.5", basicValue)) - assertEquals("3d 18h 18s", formatter.formatTime(mContext, "90.005", basicValue)) + assertEquals("30m", "0.5".formatTime()) + assertEquals("3m", "0.05".formatTime()) + assertEquals("18s", "0.005".formatTime()) + assertEquals("1d 4h", "28".formatTime()) + assertEquals("3d 18h", "90".formatTime()) + assertEquals("3d 18h 30m", "90.5".formatTime()) + assertEquals("3d 18h 18s", "90.005".formatTime()) // MINUTES basicValue = BigDecimal.valueOf(60_000_000_000_000_000_000.0) - assertEquals("30s", formatter.formatTime(mContext, "0.5", basicValue)) - assertEquals("3s", formatter.formatTime(mContext, "0.05", basicValue)) - assertEquals("300ms", formatter.formatTime(mContext, "0.005", basicValue)) - assertEquals("28m", formatter.formatTime(mContext, "28", basicValue)) - assertEquals("1h 30m", formatter.formatTime(mContext, "90", basicValue)) - assertEquals("1h 30m 30s", formatter.formatTime(mContext, "90.5", basicValue)) - assertEquals("1h 30m 300ms", formatter.formatTime(mContext, "90.005", basicValue)) + assertEquals("30s", "0.5".formatTime()) + assertEquals("3s", "0.05".formatTime()) + assertEquals("300ms", "0.005".formatTime()) + assertEquals("28m", "28".formatTime()) + assertEquals("1h 30m", "90".formatTime()) + assertEquals("1h 30m 30s", "90.5".formatTime()) + assertEquals("1h 30m 300ms", "90.005".formatTime()) // SECONDS basicValue = BigDecimal.valueOf(1_000_000_000_000_000_000) - assertEquals("500ms", formatter.formatTime(mContext, "0.5", basicValue)) - assertEquals("50ms", formatter.formatTime(mContext, "0.05", basicValue)) - assertEquals("5ms", formatter.formatTime(mContext, "0.005", basicValue)) - assertEquals("28s", formatter.formatTime(mContext, "28", basicValue)) - assertEquals("1m 30s", formatter.formatTime(mContext, "90", basicValue)) - assertEquals("1m 30s 500ms", formatter.formatTime(mContext, "90.5", basicValue)) - assertEquals("1m 30s 5ms", formatter.formatTime(mContext, "90.005", basicValue)) + assertEquals("500ms", "0.5".formatTime()) + assertEquals("50ms", "0.05".formatTime()) + assertEquals("5ms", "0.005".formatTime()) + assertEquals("28s", "28".formatTime()) + assertEquals("1m 30s", "90".formatTime()) + assertEquals("1m 30s 500ms", "90.5".formatTime()) + assertEquals("1m 30s 5ms", "90.005".formatTime()) // MILLISECONDS basicValue = BigDecimal.valueOf(1_000_000_000_000_000) - assertEquals("500µs", formatter.formatTime(mContext, "0.5", basicValue)) - assertEquals("50µs", formatter.formatTime(mContext, "0.05", basicValue)) - assertEquals("5µs", formatter.formatTime(mContext, "0.005", basicValue)) - assertEquals("28ms", formatter.formatTime(mContext, "28", basicValue)) - assertEquals("90ms", formatter.formatTime(mContext, "90", basicValue)) - assertEquals("90ms 500µs", formatter.formatTime(mContext, "90.5", basicValue)) - assertEquals("90ms 5µs", formatter.formatTime(mContext, "90.005", basicValue)) + assertEquals("500µs", "0.5".formatTime()) + assertEquals("50µs", "0.05".formatTime()) + assertEquals("5µs", "0.005".formatTime()) + assertEquals("28ms", "28".formatTime()) + assertEquals("90ms", "90".formatTime()) + assertEquals("90ms 500µs", "90.5".formatTime()) + assertEquals("90ms 5µs", "90.005".formatTime()) // MICROSECONDS basicValue = BigDecimal.valueOf(1_000_000_000_000) - assertEquals("500ns", formatter.formatTime(mContext, "0.5", basicValue)) - assertEquals("50ns", formatter.formatTime(mContext, "0.05", basicValue)) - assertEquals("5ns", formatter.formatTime(mContext, "0.005", basicValue)) - assertEquals("28µs", formatter.formatTime(mContext, "28", basicValue)) - assertEquals("90µs", formatter.formatTime(mContext, "90", basicValue)) - assertEquals("90µs 500ns", formatter.formatTime(mContext, "90.5", basicValue)) - assertEquals("90µs 5ns", formatter.formatTime(mContext, "90.005", basicValue)) + assertEquals("500ns", "0.5".formatTime()) + assertEquals("50ns", "0.05".formatTime()) + assertEquals("5ns", "0.005".formatTime()) + assertEquals("28µs", "28".formatTime()) + assertEquals("90µs", "90".formatTime()) + assertEquals("90µs 500ns", "90.5".formatTime()) + assertEquals("90µs 5ns", "90.005".formatTime()) // NANOSECONDS basicValue = BigDecimal.valueOf(1_000_000_000) - assertEquals("500 000 000as", formatter.formatTime(mContext, "0.5", basicValue)) - assertEquals("50 000 000as", formatter.formatTime(mContext, "0.05", basicValue)) - assertEquals("5 000 000as", formatter.formatTime(mContext, "0.005", basicValue)) - assertEquals("28ns", formatter.formatTime(mContext, "28", basicValue)) - assertEquals("90ns", formatter.formatTime(mContext, "90", basicValue)) - assertEquals("90ns 500 000 000as", formatter.formatTime(mContext, "90.5", basicValue)) - assertEquals("90ns 5 000 000as", formatter.formatTime(mContext, "90.005", basicValue)) + assertEquals("500 000 000as", "0.5".formatTime()) + assertEquals("50 000 000as", "0.05".formatTime()) + assertEquals("5 000 000as", "0.005".formatTime()) + assertEquals("28ns", "28".formatTime()) + assertEquals("90ns", "90".formatTime()) + assertEquals("90ns 500 000 000as", "90.5".formatTime()) + assertEquals("90ns 5 000 000as", "90.005".formatTime()) // ATTOSECONDS basicValue = BigDecimal.valueOf(1) - assertEquals("0.5", formatter.formatTime(mContext, "0.5", basicValue)) - assertEquals("0.05", formatter.formatTime(mContext, "0.05", basicValue)) - assertEquals("0.005", formatter.formatTime(mContext, "0.005", basicValue)) - assertEquals("28", formatter.formatTime(mContext, "28", basicValue)) - assertEquals("90", formatter.formatTime(mContext, "90", basicValue)) - assertEquals("90.5", formatter.formatTime(mContext, "90.5", basicValue)) - assertEquals("90.005", formatter.formatTime(mContext, "90.005", basicValue)) - } - - @Test - fun fromSeparatorToSpacesTest() { - formatter.setSeparator(Separator.SPACES) - assertEquals("123 456.789", formatter.fromSeparator("123,456.789", Separator.COMMA)) - assertEquals("123 456.789", formatter.fromSeparator("123 456.789", Separator.SPACES)) - assertEquals("123 456.789", formatter.fromSeparator("123.456,789", Separator.PERIOD)) - } - - @Test - fun fromSeparatorToPeriodTest() { - formatter.setSeparator(Separator.PERIOD) - assertEquals("123.456,789", formatter.fromSeparator("123,456.789", Separator.COMMA)) - assertEquals("123.456,789", formatter.fromSeparator("123 456.789", Separator.SPACES)) - assertEquals("123.456,789", formatter.fromSeparator("123.456,789", Separator.PERIOD)) - } - - @Test - fun fromSeparatorToCommaTest() { - formatter.setSeparator(Separator.COMMA) - assertEquals("123,456.789", formatter.fromSeparator("123,456.789", Separator.COMMA)) - assertEquals("123,456.789", formatter.fromSeparator("123 456.789", Separator.SPACES)) - assertEquals("123,456.789", formatter.fromSeparator("123.456,789", Separator.PERIOD)) + assertEquals("0.5", "0.5".formatTime()) + assertEquals("0.05", "0.05".formatTime()) + assertEquals("0.005", "0.005".formatTime()) + assertEquals("28", "28".formatTime()) + assertEquals("90", "90".formatTime()) + assertEquals("90.5", "90.5".formatTime()) + assertEquals("90.005", "90.005".formatTime()) } } \ No newline at end of file diff --git a/data/common/build.gradle.kts b/data/common/build.gradle.kts index ae906d33..6c1f9cf4 100644 --- a/data/common/build.gradle.kts +++ b/data/common/build.gradle.kts @@ -26,4 +26,5 @@ android { dependencies { implementation(project(mapOf("path" to ":core:base"))) + testImplementation(libs.junit) } \ No newline at end of file diff --git a/data/common/src/main/java/com/sadellie/unitto/data/common/BigDecimalUtils.kt b/data/common/src/main/java/com/sadellie/unitto/data/common/BigDecimalUtils.kt index 16843cf9..0400b469 100644 --- a/data/common/src/main/java/com/sadellie/unitto/data/common/BigDecimalUtils.kt +++ b/data/common/src/main/java/com/sadellie/unitto/data/common/BigDecimalUtils.kt @@ -64,11 +64,7 @@ fun BigDecimal.setMinimumRequiredScale(prefScale: Int): BigDecimal { /** * Removes all trailing zeroes. - * - * @throws NumberFormatException if value is bigger than [Double.MAX_VALUE] to avoid memory overflow. */ fun BigDecimal.trimZeros(): BigDecimal { - if (this.abs() > BigDecimal.valueOf(Double.MAX_VALUE)) throw NumberFormatException() - return if (this.compareTo(BigDecimal.ZERO) == 0) BigDecimal.ZERO else this.stripTrailingZeros() } diff --git a/data/common/src/main/java/com/sadellie/unitto/data/common/StringUtils.kt b/data/common/src/main/java/com/sadellie/unitto/data/common/StringUtils.kt index 7e52fd33..dea0e339 100644 --- a/data/common/src/main/java/com/sadellie/unitto/data/common/StringUtils.kt +++ b/data/common/src/main/java/com/sadellie/unitto/data/common/StringUtils.kt @@ -18,6 +18,8 @@ package com.sadellie.unitto.data.common +import com.sadellie.unitto.core.base.Token + /** * Compute Levenshtein Distance between this string and [secondString]. Doesn't matter which string is * first. @@ -58,3 +60,18 @@ fun String.lev(secondString: String): Int { return cost[this.length] } + +fun String.isExpression(): Boolean { + + if (isEmpty()) return false + + // Positive numbers and zero + if (all { it.toString() in Token.Digit.allWithDot }) return false + + // Negative numbers + // Needs to start with an negative + if (this.first().toString() != Token.Operator.minus) return true + + // Rest of the string must be just like positive + return this.drop(1).isExpression() +} diff --git a/data/common/src/test/java/com/sadellie/unitto/data/common/IsExpressionText.kt b/data/common/src/test/java/com/sadellie/unitto/data/common/IsExpressionText.kt new file mode 100644 index 00000000..2f581428 --- /dev/null +++ b/data/common/src/test/java/com/sadellie/unitto/data/common/IsExpressionText.kt @@ -0,0 +1,49 @@ +/* + * Unitto is a unit converter for Android + * Copyright (c) 2023 Elshan Agaev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.sadellie.unitto.data.common + +import org.junit.Assert.assertEquals +import org.junit.Test + + +class IsExpressionText { + @Test + fun `empty string`() = assertEquals(false, "".isExpression()) + + @Test + fun `positive real number`() = assertEquals(false, "123".isExpression()) + + @Test + fun `positive float`() = assertEquals(false, "123.123".isExpression()) + + @Test + fun `negative real`() = assertEquals(false, "−123".isExpression()) + + @Test + fun `negative float`() = assertEquals(false, "−123.123".isExpression()) + + @Test + fun `super negative float`() = assertEquals(false, "−−123.123".isExpression()) + + @Test + fun expression1() = assertEquals(true, "123.123+456".isExpression()) + + @Test + fun expression2() = assertEquals(true, "−123.123+456".isExpression()) +} diff --git a/data/evaluatto/.gitignore b/data/evaluatto/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/data/evaluatto/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/data/evaluatto/build.gradle.kts b/data/evaluatto/build.gradle.kts new file mode 100644 index 00000000..30a2557b --- /dev/null +++ b/data/evaluatto/build.gradle.kts @@ -0,0 +1,31 @@ +/* + * Unitto is a unit converter for Android + * Copyright (c) 2023 Elshan Agaev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +plugins { + id("unitto.library") +} + +android { + // Different namespace. Possible promotion to a separate project. + namespace = "io.github.sadellie.evaluatto" +} + +dependencies { + implementation(project(mapOf("path" to ":core:base"))) + testImplementation(libs.junit) +} diff --git a/data/evaluatto/consumer-rules.pro b/data/evaluatto/consumer-rules.pro new file mode 100644 index 00000000..e69de29b diff --git a/data/evaluatto/src/main/AndroidManifest.xml b/data/evaluatto/src/main/AndroidManifest.xml new file mode 100644 index 00000000..7bdbce91 --- /dev/null +++ b/data/evaluatto/src/main/AndroidManifest.xml @@ -0,0 +1,22 @@ + + + + + + \ No newline at end of file diff --git a/data/evaluatto/src/main/java/io/github/sadellie/evaluatto/Expression.kt b/data/evaluatto/src/main/java/io/github/sadellie/evaluatto/Expression.kt new file mode 100644 index 00000000..316c07d2 --- /dev/null +++ b/data/evaluatto/src/main/java/io/github/sadellie/evaluatto/Expression.kt @@ -0,0 +1,311 @@ +/* + * Unitto is a unit converter for Android + * Copyright (c) 2023 Elshan Agaev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package io.github.sadellie.evaluatto + +import com.sadellie.unitto.core.base.MAX_PRECISION +import com.sadellie.unitto.core.base.Token +import java.math.BigDecimal +import java.math.MathContext +import java.math.RoundingMode +import kotlin.math.cos +import kotlin.math.sin +import kotlin.math.tan +import kotlin.math.acos +import kotlin.math.asin +import kotlin.math.atan +import kotlin.math.ln +import kotlin.math.log +import kotlin.math.exp +import kotlin.math.pow + +sealed class ExpressionException(override val message: String): Exception(message) { + class DivideByZero : ExpressionException("Can't divide by zero") + class FactorialCalculation : ExpressionException("Can calculate factorial of non-negative real numbers only") + class BadExpression : ExpressionException("Invalid expression. Probably some operator lacks argument") + class TooBig : ExpressionException("Value is too big") +} + +class Expression(input: String, private val radianMode: Boolean = true) { + private val tokens = Tokenizer(input).tokenize() + private var cursorPosition = 0 + + /** + * Expression := [ "-" ] Term { ("+" | "-") Term } + * + * Term := Factor { ( "*" | "/" ) Factor } + * + * Factor := RealNumber | "(" Expression ")" + * + * RealNumber := Digit{Digit} | [ Digit ] "." {Digit} + * + * Digit := "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" + */ + fun calculate(): BigDecimal { + try { + return parseExpression() + } catch (e: UninitializedPropertyAccessException) { + throw ExpressionException.BadExpression() + } + } + + // Null when at the end of expression + private fun peek() = tokens.getOrNull(cursorPosition) ?: "" + + private fun moveIfMatched(token: String): Boolean { + if (peek() == token) { + // Move cursor + cursorPosition++ + return true + } + return false + } + + // Expression := [ "-" ] Term { ("+" | "-") Term } + private fun parseExpression(): BigDecimal { + var expression = parseTerm() + + while (peek() in listOf(Token.Operator.plus, Token.Operator.minus)) { + when { + moveIfMatched(Token.Operator.plus) -> expression += parseTerm() + moveIfMatched(Token.Operator.minus) -> expression -= parseTerm() + } + } + return expression + } + + // Term := Factor { ( "*" | "/" ) Factor } + private fun parseTerm(): BigDecimal { + var expression = parseFactor() + + while (peek() in listOf(Token.Operator.multiply, Token.Operator.divide)) { + when { + moveIfMatched(Token.Operator.multiply) -> expression = + expression.multiply(parseFactor()) + + moveIfMatched(Token.Operator.divide) -> { + val divisor = parseFactor() + if (divisor.compareTo(BigDecimal.ZERO) == 0) throw ExpressionException.DivideByZero() + + expression = expression.divide(divisor, RoundingMode.HALF_EVEN) + } + } + } + return expression + } + + // Factor := RealNumber | "(" Expression ")" + private fun parseFactor(negative: Boolean = false): BigDecimal { + // This will throw Exception if some function lacks argument, for example: "cos()" or "600^" + lateinit var expr: BigDecimal + + fun parseFuncParentheses(): BigDecimal { + return if (moveIfMatched(Token.Operator.leftBracket)) { + // Parse in parentheses + val res = parseExpression() + + // Check if parentheses is closed + if (!moveIfMatched(Token.Operator.rightBracket)) throw Exception("Closing bracket is missing") + res + } else { + parseFactor() + } + } + + // Unary plus + if (moveIfMatched(Token.Operator.plus)) return parseFactor() + + // Unary minus + if (moveIfMatched(Token.Operator.minus)) { + return -parseFactor(true) + } + + // Parentheses + if (moveIfMatched(Token.Operator.leftBracket)) { + // Parse in parentheses + expr = parseExpression() + + // Check if parentheses is closed + if (!moveIfMatched(Token.Operator.rightBracket)) throw Exception("Closing bracket is missing") + } + + // Numbers + val possibleNumber = peek() + // We know that if next token starts with a digit or dot, it can be converted into BigDecimal + // Ugly + if (possibleNumber.isNotEmpty()) { + if (Token.Digit.allWithDot.contains(possibleNumber.first().toString())) { + expr = BigDecimal(possibleNumber).setScale(MAX_PRECISION) + cursorPosition++ + } + } + + // PI + if (moveIfMatched(Token.Const.pi)) { + expr = BigDecimal.valueOf(Math.PI) + } + + // e + if (moveIfMatched(Token.Const.e)) { + expr = BigDecimal.valueOf(Math.E) + } + + // sqrt + if (moveIfMatched(Token.Operator.sqrt)) { + expr = parseFuncParentheses().pow(BigDecimal(0.5)) + } + + // sin + if (moveIfMatched(Token.Func.sin)) { + expr = parseFuncParentheses().sin(radianMode) + } + + // cos + if (moveIfMatched(Token.Func.cos)) { + expr = parseFuncParentheses().cos(radianMode) + } + + // tan + if (moveIfMatched(Token.Func.tan)) { + expr = parseFuncParentheses().tan(radianMode) + } + + // arsin + if (moveIfMatched(Token.Func.arsin)) { + expr = parseFuncParentheses().arsin(radianMode) + } + + // arcos + if (moveIfMatched(Token.Func.arcos)) { + expr = parseFuncParentheses().arcos(radianMode) + } + + // actan + if (moveIfMatched(Token.Func.actan)) { + expr = parseFuncParentheses().artan(radianMode) + } + + // ln + if (moveIfMatched(Token.Func.ln)) { + expr = parseFuncParentheses().ln() + } + + // log + if (moveIfMatched(Token.Func.log)) { + expr = parseFuncParentheses().log() + } + + // exp + if (moveIfMatched(Token.Func.exp)) { + expr = parseFuncParentheses().exp() + } + + // Power + if (moveIfMatched(Token.Operator.power)) { + expr = expr.pow(parseFactor()) + } + + // Modulo + if (moveIfMatched(Token.Operator.modulo)) { + expr = expr.remainder(parseFactor()) + } + + // Factorial + if (moveIfMatched(Token.Operator.factorial)) { + if (negative) throw ExpressionException.FactorialCalculation() + expr = expr.factorial() + } + + return expr + } +} + +private fun BigDecimal.sin(radianMode: Boolean): BigDecimal { + val angle: Double = if (radianMode) this.toDouble() else Math.toRadians(this.toDouble()) + return sin(angle).toBigDecimal() +} + +private fun BigDecimal.arsin(radianMode: Boolean): BigDecimal { + val angle: Double = asin(this.toDouble()) + return (if (radianMode) angle else Math.toDegrees(angle)).toBigDecimal() +} + +private fun BigDecimal.cos(radianMode: Boolean): BigDecimal { + val angle: Double = if (radianMode) this.toDouble() else Math.toRadians(this.toDouble()) + return cos(angle).toBigDecimal() +} + +private fun BigDecimal.arcos(radianMode: Boolean): BigDecimal { + val angle: Double = acos(this.toDouble()) + return (if (radianMode) angle else Math.toDegrees(angle)).toBigDecimal() +} + +private fun BigDecimal.tan(radianMode: Boolean): BigDecimal { + val angle: Double = if (radianMode) this.toDouble() else Math.toRadians(this.toDouble()) + return tan(angle).toBigDecimal() +} + +private fun BigDecimal.artan(radianMode: Boolean): BigDecimal { + val angle: Double = atan(this.toDouble()) + return (if (radianMode) angle else Math.toDegrees(angle)).toBigDecimal() +} + +private fun BigDecimal.ln(): BigDecimal { + return ln(this.toDouble()).toBigDecimal() +} + +private fun BigDecimal.log(): BigDecimal { + return log(this.toDouble(), 10.0).toBigDecimal() +} + +private fun BigDecimal.exp(): BigDecimal { + return exp(this.toDouble()).toBigDecimal() +} + +private fun BigDecimal.pow(n: BigDecimal): BigDecimal { + val mathContext: MathContext = MathContext.DECIMAL64 + + var right = n + val signOfRight = right.signum() + right = right.multiply(signOfRight.toBigDecimal()) + val remainderOfRight = right.remainder(BigDecimal.ONE) + val n2IntPart = right.subtract(remainderOfRight) + val intPow = pow(n2IntPart.intValueExact(), mathContext) + val doublePow = BigDecimal( + toDouble().pow(remainderOfRight.toDouble()) + ) + + var result = intPow.multiply(doublePow, mathContext) + if (signOfRight == -1) result = + BigDecimal.ONE.divide(result, mathContext.precision, RoundingMode.HALF_UP) + + return result +} + +private fun BigDecimal.factorial(): BigDecimal { + if (this.remainder(BigDecimal.ONE).compareTo(BigDecimal.ZERO) != 0) throw ExpressionException.FactorialCalculation() + if (this < BigDecimal.ZERO) throw ExpressionException.FactorialCalculation() + + println("got $this") + + var expr = this + for (i in 1 until this.toInt()) { + expr *= BigDecimal(i) + } + return expr +} diff --git a/data/evaluatto/src/main/java/io/github/sadellie/evaluatto/Tokenizer.kt b/data/evaluatto/src/main/java/io/github/sadellie/evaluatto/Tokenizer.kt new file mode 100644 index 00000000..e3a74bc5 --- /dev/null +++ b/data/evaluatto/src/main/java/io/github/sadellie/evaluatto/Tokenizer.kt @@ -0,0 +1,238 @@ +/* + * Unitto is a unit converter for Android + * Copyright (c) 2023 Elshan Agaev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package io.github.sadellie.evaluatto + +import com.sadellie.unitto.core.base.Token + +sealed class TokenizerException(override val message: String) : Exception(message) { + class BadNumber : TokenizerException("Number has multiple commas in it") +} + +class Tokenizer(private val streamOfTokens: String) { + // Do this in init? + fun tokenize(): List { + var cursor = 0 + val tokens: MutableList = mutableListOf() + + while (cursor != streamOfTokens.length) { + val nextToken = peekTokenAfter(cursor) + + if (nextToken != null) { + tokens.add(nextToken) + cursor += nextToken.length + } else { + // Didn't find any token, move left slowly (by 1 symbol) + cursor++ + } + } + + return tokens.repairLexicon() + } + + private fun peekTokenAfter(cursor: Int): String? { + Token.expressionTokens.forEach { token -> + val subs = streamOfTokens + .substring( + cursor, + (cursor + token.length).coerceAtMost(streamOfTokens.length) + ) + if (subs == token) { + // Got a digit, see if there are other digits coming after + if (token in Token.Digit.allWithDot) { + val number = streamOfTokens + .substring(cursor) + .takeWhile { Token.Digit.allWithDot.contains(it.toString()) } + + if (number.count { it.toString() == Token.Digit.dot } > 1) { + throw TokenizerException.BadNumber() + } + + return number + } + return token + } + } + return null + } + + private fun List.repairLexicon(): List { + return this + .missingClosingBrackets() + .missingMultiply() + .unpackAlPercents() + } + + private fun List.missingClosingBrackets(): List { + val leftBracket = this.count { it == Token.Operator.leftBracket } + val rightBrackets = this.count { it == Token.Operator.rightBracket } + val neededBrackets = leftBracket - rightBrackets + + if (neededBrackets <= 0) return this + + var fixed = this + repeat(neededBrackets) { + fixed = fixed + Token.Operator.rightBracket + } + return fixed + } + + private fun List.missingMultiply(): List { + val results = this.toMutableList() + val insertIndexes = mutableListOf() + + // Records the index if it needs a multiply symbol + fun needsMultiply(index: Int) { + val tokenInFront = results.getOrNull(index - 1) ?: return + + when { + tokenInFront.first().toString() in Token.Digit.allWithDot || + tokenInFront == Token.Operator.rightBracket || + tokenInFront in Token.Const.all -> { + // Can't add token now, it will modify tokens list (we are looping over it) + insertIndexes.add(index + insertIndexes.size) + } + } + } + + results.forEachIndexed { index, s -> + when (s) { + Token.Operator.leftBracket, + Token.Operator.sqrt, + in Token.Const.all, + in Token.Func.all -> needsMultiply(index) + } + } + + insertIndexes.forEach { + results.add(it, Token.Operator.multiply) + } + + return results + } + + private fun List.unpackAlPercents(): List { + var result = this + while (result.contains(Token.Operator.percent)) { + val percIndex = result.indexOf(Token.Operator.percent) + result = result.unpackPercentAt(percIndex) + } + return result + } + + private fun List.unpackPercentAt(percentIndex: Int): List { + var cursor = percentIndex + + // get whatever is the percentage + val percentage = this.getNumberOrExpressionBefore(percentIndex) + // Move cursor + cursor -= percentage.size + + // get the operator in front + cursor -= 1 + val operator = this.getOrNull(cursor) + + // Don't go further + if ((operator == null) or (operator !in listOf(Token.Operator.plus, Token.Operator.minus))) { + val mutList = this.toMutableList() + + // Remove percentage + mutList.removeAt(percentIndex) + + //Add opening bracket before percentage + mutList.add(percentIndex - percentage.size, Token.Operator.leftBracket) + + // Add "/ 100" and closing bracket + mutList.addAll(percentIndex + 1, listOf(Token.Operator.divide, "100", Token.Operator.rightBracket)) + + return mutList + } + // Get the base + val base = this.getBaseBefore(cursor) + val mutList = this.toMutableList() + + // Remove percentage + mutList.removeAt(percentIndex) + + //Add opening bracket before percentage + mutList.add(percentIndex - percentage.size, Token.Operator.leftBracket) + + // Add "/ 100" and other stuff + mutList.addAll( + percentIndex+1, + listOf( + Token.Operator.divide, + "100", + Token.Operator.multiply, + Token.Operator.leftBracket, + *base.toTypedArray(), + Token.Operator.rightBracket, + Token.Operator.rightBracket + ) + ) + + return mutList + } + + private fun List.getNumberOrExpressionBefore(pos: Int): List { + val digits = Token.Digit.all.map { it[0] } + + val tokenInFront = this[pos-1] + + // Just number + if (tokenInFront.all { it in digits }) return listOf(tokenInFront) + + // Not just a number. Probably expression in brackets. + if (tokenInFront != Token.Operator.rightBracket) throw Exception("Unexpected token before the percentage") + + // Start walking left until we get balanced brackets + var cursor = pos - 1 + var leftBrackets = 0 + var rightBrackets = 1 // We set 1 because we start with closing bracket + + while (leftBrackets != rightBrackets) { + cursor-- + val currentToken = this[cursor] + if (currentToken == Token.Operator.leftBracket) leftBrackets++ + if (currentToken == Token.Operator.rightBracket) rightBrackets++ + } + + return this.subList(cursor, pos) + } + + private fun List.getBaseBefore(pos: Int): List { + var cursor = pos + var leftBrackets = 0 + var rightBrackets = 0 + + while ((--cursor >= 0)) { + val currentToken = this[cursor] + + if (currentToken == Token.Operator.leftBracket) leftBrackets++ + if (currentToken == Token.Operator.rightBracket) rightBrackets++ + + if (leftBrackets > rightBrackets) break + } + + // Return cursor back to last token + cursor += 1 + + return this.subList(cursor, pos) + } + +} diff --git a/data/evaluatto/src/test/java/io/github/sadellie/evaluatto/ExpressionComplexTest.kt b/data/evaluatto/src/test/java/io/github/sadellie/evaluatto/ExpressionComplexTest.kt new file mode 100644 index 00000000..d530ebd6 --- /dev/null +++ b/data/evaluatto/src/test/java/io/github/sadellie/evaluatto/ExpressionComplexTest.kt @@ -0,0 +1,60 @@ +/* + * Unitto is a unit converter for Android + * Copyright (c) 2023 Elshan Agaev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package io.github.sadellie.evaluatto + +import org.junit.Test + +class ExpressionComplexTest { + + @Test + fun expression1() = assertExpr("94×π×89×cos(0.5)−3!÷9^(2)×√8", "23064.9104578494") + + @Test + fun expression2() = assertExpr("√(25)×2+10÷2", "15") + + @Test + fun expression3() = assertExpr("(3+4)×(5−2)", "21") + + @Test + fun expression4() = assertExpr("8÷4+2×3", "8") + + @Test + fun expression5() = assertExpr("2^3+4^2−5×6", "-6") + + @Test + fun expression6() = assertExpr("(10−2)^2÷8+3×2", "14") + + @Test + fun expression7() = assertExpr("7!÷3!−5!÷2!", "780") + + @Test + fun expression8() = assertExpr("(2^2+3^3)÷5−√(16)×2", "-1.8") + + @Test + fun expression9() = assertExpr("10×log(100)+2^4−3^2", "27") + + @Test + fun expression10() = assertExpr("sin(π÷3)×cos(π÷6)+tan(π÷4)−√3", "0.017949192431123") + + @Test + fun expression11() = assertExpr("2^6−2^5+2^4−2^3+2^−2^1+2^0", "41.25") + + @Test + fun expression12() = assertExpr("2×(3+4)×(5−2)÷6", "7") +} diff --git a/data/evaluatto/src/test/java/io/github/sadellie/evaluatto/ExpressionExceptionsTest.kt b/data/evaluatto/src/test/java/io/github/sadellie/evaluatto/ExpressionExceptionsTest.kt new file mode 100644 index 00000000..262c5841 --- /dev/null +++ b/data/evaluatto/src/test/java/io/github/sadellie/evaluatto/ExpressionExceptionsTest.kt @@ -0,0 +1,42 @@ +/* + * Unitto is a unit converter for Android + * Copyright (c) 2023 Elshan Agaev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package io.github.sadellie.evaluatto + +import org.junit.Test + +class ExpressionExceptionsTest { + + @Test + fun `divide by zero`() = assertExprFail(ExpressionException.DivideByZero::class.java, "2÷0") + + @Test + fun `factorial of float`() = assertExprFail(ExpressionException.FactorialCalculation::class.java, "3.2!") + + @Test + fun `factorial of negative`() = assertExprFail(ExpressionException.FactorialCalculation::class.java, "−5!") + + @Test + fun `factorial of negative2`() = assertExprFail(ExpressionException.FactorialCalculation::class.java, "(−5)!") + + @Test + fun `ugly ahh expression`() = assertExprFail(ExpressionException.BadExpression::class.java, "100+cos()") + + @Test + fun `ugly ahh expression2`() = assertExprFail(TokenizerException.BadNumber::class.java, "...") +} diff --git a/data/evaluatto/src/test/java/io/github/sadellie/evaluatto/ExpressionSimpleTest.kt b/data/evaluatto/src/test/java/io/github/sadellie/evaluatto/ExpressionSimpleTest.kt new file mode 100644 index 00000000..24d0b75e --- /dev/null +++ b/data/evaluatto/src/test/java/io/github/sadellie/evaluatto/ExpressionSimpleTest.kt @@ -0,0 +1,111 @@ +/* + * Unitto is a unit converter for Android + * Copyright (c) 2023 Elshan Agaev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package io.github.sadellie.evaluatto + +import org.junit.Test + +class ExpressionSimpleTest { + + @Test + fun expression1() = assertExpr("789", "789") + + @Test + fun expression2() = assertExpr("0.1+0.2", "0.3") + + @Test + fun expression3() = assertExpr(".1+.2", "0.3") + + @Test + fun expression4() = assertExpr("789+200", "989") + + @Test + fun expression5() = assertExpr("600×7.89", "4734") + + @Test + fun expression6() = assertExpr("600÷7", "85.7142857143") + + @Test + fun expression7() = assertExpr("(200+200)×200", "80000") + + @Test + fun expression8() = assertExpr("99^5", "9509900499") + + @Test + fun expression9() = assertExpr("12!", "479001600") + + @Test + fun expression10() = assertExpr("12#5", "2") + + @Test + fun `125 plus 9 percent`() = assertExpr("125+9%", "136.25") + + @Test + fun expression11() = assertExpr("12×√5", "26.8328157300") + + @Test + fun expression12() = assertExpr("sin(42)", "-0.9165215479") + + @Test + fun expression13() = assertExpr("sin(42)", "0.6691306064", radianMode = false) + + @Test + fun expression14() = assertExpr("cos(42)", "-0.3999853150") + + @Test + fun expression15() = assertExpr("cos(42)", "0.7431448255", radianMode = false) + + @Test + fun expression16() = assertExpr("tan(42)", "2.2913879924") + + @Test + fun expression17() = assertExpr("tan(42)", "0.9004040443", radianMode = false) + + @Test + fun expression18() = assertExpr("sin⁻¹(.69)", "0.7614890527") + + @Test + fun expression19() = assertExpr("sin⁻¹(.69)", "43.6301088679", radianMode = false) + + @Test + fun expression20() = assertExpr("cos⁻¹(.69)", "0.8093072740") + + @Test + fun expression21() = assertExpr("cos⁻¹(.69)", "46.3698911321", radianMode = false) + + @Test + fun expression22() = assertExpr("tan⁻¹(.69)", "0.6039829783") + + @Test + fun expression23() = assertExpr("tan⁻¹(.69)", "34.6056755516", radianMode = false) + + @Test + fun expression24() = assertExpr("ln(.69)", "-0.3710636814") + + @Test + fun expression25() = assertExpr("log(.69)", "-0.1611509093") + + @Test + fun expression26() = assertExpr("exp(3)", "20.0855369232") + + @Test + fun expression27() = assertExpr("π", "3.1415926536") + + @Test + fun expression28() = assertExpr("e", "2.7182818285") +} diff --git a/data/evaluatto/src/test/java/io/github/sadellie/evaluatto/FixLexiconTest.kt b/data/evaluatto/src/test/java/io/github/sadellie/evaluatto/FixLexiconTest.kt new file mode 100644 index 00000000..d0d9d4fe --- /dev/null +++ b/data/evaluatto/src/test/java/io/github/sadellie/evaluatto/FixLexiconTest.kt @@ -0,0 +1,122 @@ +/* + * Unitto is a unit converter for Android + * Copyright (c) 2023 Elshan Agaev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package io.github.sadellie.evaluatto + +import org.junit.Test + +class FixLexiconTest { + @Test + fun `missing multiply`() { + assertLex( + "2×(69−420)", "2(69−420)" + ) + + assertLex( + "0.×(69−420)", "0.(69−420)" + ) + + assertLex( + ".0×(69−420)", ".0(69−420)" + ) + + assertLex( + ".×(69−420)", ".(69−420)" + ) + + assertLex( + "2×(69−420)×(23−4)×cos(9)×tan((sin⁻¹(.9)))", + "2(69−420)(23−4)cos(9)tan((sin⁻¹(.9)))" + ) + + assertLex( + "e×e+π", "ee+π" + ) + } + + @Test + fun `balanced brackets`() { + assertLex( + "123×(12+4)", "123(12+4" + ) + + assertLex( + "12312+4", "12312+4" + ) + + assertLex( + "123)))12+4", "123)))12+4" + ) + + assertLex( + "sin(cos(tan(3)))", "sin(cos(tan(3" + ) + + assertLex( + "sin(cos(tan(3)))", "sin(cos(tan(3)" + ) + + assertLex( + "sin(cos(tan(3)))", "sin(cos(tan(3))" + ) + } + + @Test + fun `unpack percentage`() { + // 132.5+14% −> 132.5+132.5*0.14 + assertLex( + "132.5+(14÷100×(132.5))", "132.5+14%" + ) + + // 132.5+(14)% −> 132.5+(14)/100*132.5 + assertLex( + "132.5+((14)÷100×(132.5))" , "132.5+(14)%" + ) + + // 132.5+(15+4)% −> 132.5+(15+4)*132.5/100 + assertLex( + "132.5+((15+4)÷100×(132.5))", "132.5+(15+4)%" + ) + + // (132.5+12%)+(15+4)% −> (132.5+12/100*132.5)+(15+4)/100*(132.5+12/100*132.5) + assertLex( + "(132.5+(12÷100×(132.5)))+((15+4)÷100×((132.5+(12÷100×(132.5)))))", "(132.5+12%)+(15+4)%" + ) + + // 2% −> 2/100 + assertLex( + "(2÷100)", "2%" + ) + + assertLex( + "((2)÷100)", "(2)%" + ) + + assertLex( + "(132.5+5)+(90÷100×((132.5+5)))", "(132.5+5)+90%" + ) + + assertLex( + "((90÷100)+(90÷100×((90÷100))))", "(90%+90%)" + ) + + assertLex( + "((90÷100)÷(90÷100))+((90÷100)−(90÷100×((90÷100))))", "(90%÷90%)+(90%−90%)" + ) + } +} diff --git a/data/evaluatto/src/test/java/io/github/sadellie/evaluatto/Helpers.kt b/data/evaluatto/src/test/java/io/github/sadellie/evaluatto/Helpers.kt new file mode 100644 index 00000000..8cdc1468 --- /dev/null +++ b/data/evaluatto/src/test/java/io/github/sadellie/evaluatto/Helpers.kt @@ -0,0 +1,46 @@ +/* + * Unitto is a unit converter for Android + * Copyright (c) 2023 Elshan Agaev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package io.github.sadellie.evaluatto + +import org.junit.Assert +import java.math.BigDecimal +import java.math.RoundingMode + +fun assertExpr(expr: String, result: String, radianMode: Boolean = true) = + Assert.assertEquals( + BigDecimal(result).setScale(10, RoundingMode.HALF_EVEN), + Expression(expr, radianMode).calculate().setScale(10, RoundingMode.HALF_EVEN) + ) + +fun assertExprFail( + expectedThrowable: Class?, + expr: String, + radianMode: Boolean = true +) { + Assert.assertThrows(expectedThrowable) { + val calculated = Expression(expr, radianMode = radianMode).calculate() + println(calculated) + } +} + +fun assertLex(expected: List, actual: String) = + Assert.assertEquals(expected, Tokenizer(actual).tokenize()) + +fun assertLex(expected: String, actual: String) = + Assert.assertEquals(expected, Tokenizer(actual).tokenize().joinToString("")) diff --git a/data/evaluatto/src/test/java/io/github/sadellie/evaluatto/TokenizerTest.kt b/data/evaluatto/src/test/java/io/github/sadellie/evaluatto/TokenizerTest.kt new file mode 100644 index 00000000..12a23cc4 --- /dev/null +++ b/data/evaluatto/src/test/java/io/github/sadellie/evaluatto/TokenizerTest.kt @@ -0,0 +1,50 @@ +/* + * Unitto is a unit converter for Android + * Copyright (c) 2023 Elshan Agaev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package io.github.sadellie.evaluatto + +import org.junit.Test + +class TokenizerTest { + @Test + fun tokens1() = assertLex(listOf("789"), "789") + + @Test + fun tokens2() = assertLex(listOf("789", "+", "200"), "789+200") + + @Test + fun tokens3() = assertLex(listOf("0.1", "+", "0.2"), "0.1+0.2") + + @Test + fun tokens4() = assertLex(listOf(".1", "+", ".2"), ".1+.2") + + @Test + fun tokens5() = assertLex(listOf(".1", "+", ".2"), ".1+.2") + + @Test + fun tokens6() = assertLex(listOf("789", "+", "200", "+", "cos", "(", "456", ")"), "789+200+cos(456)") + + @Test + fun tokens8() = assertLex(emptyList(), "") + + @Test + fun tokens9() = assertLex(listOf("e"), "something") // Tokenizer knows "e" + + @Test + fun tokens10() = assertLex(emptyList(), "funnyword") +} diff --git a/data/licenses/src/main/java/com/sadellie/unitto/data/licenses/Library.kt b/data/licenses/src/main/java/com/sadellie/unitto/data/licenses/Library.kt index 149bb58b..0bee1fca 100644 --- a/data/licenses/src/main/java/com/sadellie/unitto/data/licenses/Library.kt +++ b/data/licenses/src/main/java/com/sadellie/unitto/data/licenses/Library.kt @@ -28,23 +28,6 @@ data class AppLibrary( val ALL_LIBRARIES by lazy { listOf( - AppLibrary( - name = "MathParser.org-mXparser", - dev = "Mariusz Gromada", - website = "https://github.com/mariuszgromada/MathParser.org-mXparser/", - license = "Non-Commercial license", - description = "Math Parser Java Android C# .NET/MONO (.NET Framework, .NET Core, .NET " + - "Standard, .NET PCL, Xamarin.Android, Xamarin.iOS) CLS Library - a super easy, rich" + - " and flexible mathematical expression parser (expression evaluator, expression " + - "provided as plain text / strings) for JAVA and C#." - ), - AppLibrary( - name = "ExprK", - dev = "Keelar", - website = "https://github.com/Keelar/ExprK", - license = "MIT license", - description = "A simple mathematical expression evaluator for Kotlin and Java, written in Kotlin." - ), AppLibrary( name = "currency-api", dev = "Fawaz Ahmed (fawazahmed0)", diff --git a/feature/calculator/build.gradle.kts b/feature/calculator/build.gradle.kts index 1c7915d0..b69ba212 100644 --- a/feature/calculator/build.gradle.kts +++ b/feature/calculator/build.gradle.kts @@ -29,7 +29,6 @@ android { dependencies { testImplementation(libs.junit) - implementation(libs.org.mariuszgromada.math.mxparser) implementation(libs.com.github.sadellie.themmo) implementation(project(mapOf("path" to ":data:common"))) @@ -37,4 +36,5 @@ dependencies { implementation(project(mapOf("path" to ":data:database"))) implementation(project(mapOf("path" to ":data:calculator"))) implementation(project(mapOf("path" to ":data:model"))) + implementation(project(mapOf("path" to ":data:evaluatto"))) } 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 7edc7036..e08c8636 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 @@ -57,23 +57,23 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.layout.onPlaced import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.sadellie.unitto.core.base.Separator -import com.sadellie.unitto.core.ui.Formatter import com.sadellie.unitto.core.ui.common.MenuButton import com.sadellie.unitto.core.ui.common.UnittoScreenWithTopBar -import com.sadellie.unitto.core.ui.common.textfield.InputTextField +import com.sadellie.unitto.core.ui.common.textfield.ExpressionTextField +import com.sadellie.unitto.core.ui.common.textfield.UnformattedTextField import com.sadellie.unitto.data.model.HistoryItem import com.sadellie.unitto.feature.calculator.components.CalculatorKeyboard import com.sadellie.unitto.feature.calculator.components.DragDownView import com.sadellie.unitto.feature.calculator.components.HistoryList import kotlinx.coroutines.launch import java.text.SimpleDateFormat -import java.util.* +import java.util.Locale import kotlin.math.abs import kotlin.math.roundToInt @@ -89,9 +89,9 @@ internal fun CalculatorRoute( uiState = uiState.value, navigateToMenu = navigateToMenu, navigateToSettings = navigateToSettings, - addSymbol = viewModel::addSymbol, - clearSymbols = viewModel::clearSymbols, - deleteSymbol = viewModel::deleteSymbol, + addSymbol = viewModel::addTokens, + clearSymbols = viewModel::clearInput, + deleteSymbol = viewModel::deleteTokens, onCursorChange = viewModel::onCursorChange, toggleAngleMode = viewModel::toggleCalculatorMode, evaluate = viewModel::evaluate, @@ -107,7 +107,7 @@ private fun CalculatorScreen( addSymbol: (String) -> Unit, clearSymbols: () -> Unit, deleteSymbol: () -> Unit, - onCursorChange: (IntRange) -> Unit, + onCursorChange: (TextRange) -> Unit, toggleAngleMode: () -> Unit, evaluate: () -> Unit, clearHistory: () -> Unit @@ -160,6 +160,8 @@ private fun CalculatorScreen( .fillMaxSize(), historyItems = uiState.history, historyItemHeightCallback = { historyItemHeight = it }, + formatterSymbols = uiState.formatterSymbols, + addTokens = addSymbol, ) }, textFields = { maxDragAmount -> @@ -202,28 +204,56 @@ private fun CalculatorScreen( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp) ) { - InputTextField( + ExpressionTextField( modifier = Modifier .weight(2f) .fillMaxWidth() .padding(horizontal = 8.dp), - value = uiState.input.copy( - Formatter.fromSeparator(uiState.input.text, Separator.COMMA) - ), + value = uiState.input, minRatio = 0.5f, cutCallback = deleteSymbol, pasteCallback = addSymbol, - onCursorChange = onCursorChange + onCursorChange = onCursorChange, + formatterSymbols = uiState.formatterSymbols ) if (LocalConfiguration.current.orientation == Configuration.ORIENTATION_PORTRAIT) { - InputTextField( - modifier = Modifier - .weight(1f) - .fillMaxWidth() - .padding(horizontal = 8.dp), - value = Formatter.format(uiState.output), - textColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(0.6f) - ) + when (uiState.output) { + is CalculationResult.Default -> { + var output by remember(uiState.output) { + mutableStateOf(TextFieldValue(uiState.output.text)) + } + + ExpressionTextField( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .padding(horizontal = 8.dp), + value = output, + minRatio = 1f, + onCursorChange = { output = output.copy(selection = it) }, + formatterSymbols = uiState.formatterSymbols, + textColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(0.6f), + readOnly = true, + ) + } + + else -> { + val label = uiState.output.label?.let { stringResource(it) } ?: "" + + UnformattedTextField( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .padding(horizontal = 8.dp), + value = TextFieldValue(label), + minRatio = 1f, + onCursorChange = {}, + textColor = MaterialTheme.colorScheme.error, + readOnly = true, + ) + } + } + } // Handle Box( @@ -239,8 +269,11 @@ private fun CalculatorScreen( }, numPad = { CalculatorKeyboard( - modifier = Modifier.fillMaxSize().padding(horizontal = 8.dp, vertical = 4.dp), + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 8.dp, vertical = 4.dp), radianMode = uiState.radianMode, + fractional = uiState.formatterSymbols.fractional, allowVibration = uiState.allowVibration, addSymbol = addSymbol, clearSymbols = clearSymbols, @@ -315,7 +348,7 @@ private fun PreviewCalculatorScreen() { CalculatorScreen( uiState = CalculatorUIState( input = TextFieldValue("1.2345"), - output = "1234", + output = CalculationResult.Default("1234"), history = historyItems ), navigateToMenu = {}, 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 36f46269..4a1521ff 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,13 +18,22 @@ package com.sadellie.unitto.feature.calculator +import androidx.annotation.StringRes import androidx.compose.ui.text.input.TextFieldValue +import com.sadellie.unitto.core.ui.common.textfield.FormatterSymbols import com.sadellie.unitto.data.model.HistoryItem -internal data class CalculatorUIState( +data class CalculatorUIState( val input: TextFieldValue = TextFieldValue(), - val output: String = "", + val output: CalculationResult = CalculationResult.Default(), val radianMode: Boolean = true, val history: List = emptyList(), - val allowVibration: Boolean = false + val allowVibration: Boolean = false, + val formatterSymbols: FormatterSymbols = FormatterSymbols.Spaces ) + +sealed class CalculationResult(@StringRes val label: Int? = null) { + data class Default(val text: String = "") : CalculationResult() + object DivideByZeroError : CalculationResult(R.string.divide_by_zero_error) + object Error : CalculationResult(R.string.error_label) +} 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 9c1b6081..7c12c7ad 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 @@ -18,36 +18,40 @@ package com.sadellie.unitto.feature.calculator +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.sadellie.unitto.core.base.Token +import com.sadellie.unitto.core.ui.common.textfield.AllFormatterSymbols +import com.sadellie.unitto.core.ui.common.textfield.addTokens +import com.sadellie.unitto.core.ui.common.textfield.deleteTokens import com.sadellie.unitto.data.calculator.CalculatorHistoryRepository +import com.sadellie.unitto.data.common.isExpression import com.sadellie.unitto.data.common.setMinimumRequiredScale import com.sadellie.unitto.data.common.toStringWith import com.sadellie.unitto.data.common.trimZeros import com.sadellie.unitto.data.userprefs.UserPreferences import com.sadellie.unitto.data.userprefs.UserPreferencesRepository import dagger.hilt.android.lifecycle.HiltViewModel +import io.github.sadellie.evaluatto.Expression +import io.github.sadellie.evaluatto.ExpressionException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import org.mariuszgromada.math.mxparser.Expression import java.math.BigDecimal import javax.inject.Inject -import org.mariuszgromada.math.mxparser.License as MathParserLicense -import org.mariuszgromada.math.mxparser.mXparser as MathParser @HiltViewModel internal class CalculatorViewModel @Inject constructor( private val userPrefsRepository: UserPreferencesRepository, private val calculatorHistoryRepository: CalculatorHistoryRepository, - private val textFieldController: TextFieldController ) : ViewModel() { private val _userPrefs: StateFlow = userPrefsRepository.userPreferencesFlow.stateIn( @@ -56,131 +60,91 @@ internal class CalculatorViewModel @Inject constructor( UserPreferences() ) - private val _output: MutableStateFlow = MutableStateFlow("") + private val _input: MutableStateFlow = MutableStateFlow(TextFieldValue()) + private val _output: MutableStateFlow = + MutableStateFlow(CalculationResult.Default()) private val _history = calculatorHistoryRepository.historyFlow val uiState = combine( - textFieldController.input, _output, _history, _userPrefs + _input, _output, _history, _userPrefs ) { input, output, history, userPrefs -> return@combine CalculatorUIState( input = input, output = output, radianMode = userPrefs.radianMode, history = history, - allowVibration = userPrefs.enableVibrations + allowVibration = userPrefs.enableVibrations, + formatterSymbols = AllFormatterSymbols.getById(userPrefs.separator) ) }.stateIn( viewModelScope, SharingStarted.WhileSubscribed(5000L), CalculatorUIState() ) - fun addSymbol(symbol: String) = textFieldController.addToInput(symbol) + fun addTokens(tokens: String) = _input.update { it.addTokens(tokens) } + fun deleteTokens() = _input.update { it.deleteTokens() } + fun clearInput() = _input.update { TextFieldValue() } + fun onCursorChange(selection: TextRange) = _input.update { it.copy(selection = selection) } - fun deleteSymbol() = textFieldController.delete() + // Called when user clicks "=" on a keyboard + fun evaluate() = viewModelScope.launch(Dispatchers.IO) { + when (val calculationResult = calculateInput()) { + is CalculationResult.Default -> { + if (calculationResult.text.isEmpty()) return@launch - fun clearSymbols() = textFieldController.clearInput() - - fun toggleCalculatorMode() { - viewModelScope.launch { - userPrefsRepository.updateRadianMode(!_userPrefs.value.radianMode) + calculatorHistoryRepository.add( + expression = _input.value.text, + result = calculationResult.text + ) + _input.update { + TextFieldValue(calculationResult.text, TextRange(calculationResult.text.length)) + } + _output.update { CalculationResult.Default() } + } + // Show the error + else -> _output.update { calculationResult } } } - // Called when user clicks "=" on a keyboard - fun evaluate() { - // Input and output can change while saving in history. This way we cache it here (i think) - val output = _output.value - - // Output can be empty when input and output are identical (for example when user entered - // just a number, not expression - if (output.isEmpty()) return - if (!Expression(textFieldController.inputTextWithoutFormatting().clean).checkSyntax()) return - - // Save to history - viewModelScope.launch(Dispatchers.IO) { - calculatorHistoryRepository.add( - expression = textFieldController.inputTextWithoutFormatting(), - result = output - ) - textFieldController.clearInput() - textFieldController.addToInput(output) - } - - _output.update { "" } + fun toggleCalculatorMode() = viewModelScope.launch { + userPrefsRepository.updateRadianMode(!_userPrefs.value.radianMode) } fun clearHistory() = viewModelScope.launch(Dispatchers.IO) { calculatorHistoryRepository.clear() } - fun onCursorChange(selection: IntRange) = textFieldController.moveCursor(selection) + private fun calculateInput(): CalculationResult { + val currentInput = _input.value.text + // Input is empty or not an expression, don't calculate + if (!currentInput.isExpression()) return CalculationResult.Default() - private fun calculateInput() { - val currentInput = textFieldController.inputTextWithoutFormatting() - // Input is empty, don't calculate - if (currentInput.isEmpty()) { - _output.update { "" } - return + return try { + CalculationResult.Default( + Expression(currentInput, radianMode = _userPrefs.value.radianMode) + .calculate() + .also { + if (it > BigDecimal.valueOf(Double.MAX_VALUE)) throw ExpressionException.TooBig() + } + .setMinimumRequiredScale(_userPrefs.value.digitsPrecision) + .trimZeros() + .toStringWith(_userPrefs.value.outputFormat) + ) + } catch (e: ExpressionException.DivideByZero) { + CalculationResult.DivideByZeroError + } catch (e: Exception) { + CalculationResult.Error } - - val calculated = Expression(currentInput.clean).calculate() - - // Calculation error, return empty string - if (calculated.isNaN() or calculated.isInfinite()) { - _output.update { "" } - return - } - - val calculatedBigDecimal = calculated - .toBigDecimal() - .setMinimumRequiredScale(_userPrefs.value.digitsPrecision) - .trimZeros() - - // Output will be empty if it's same as input - try { - val inputBigDecimal = BigDecimal(currentInput) - - // Input and output are identical values - if (inputBigDecimal.compareTo(calculatedBigDecimal) == 0) { - _output.update { "" } - return - } - } catch (e: NumberFormatException) { - // Cannot compare input and output - } - _output.update { - calculatedBigDecimal.toStringWith(_userPrefs.value.outputFormat) - } - return } - /** - * Clean input so that there are no syntax errors - */ - private val String.clean: String - get() { - val leftBrackets = count { it.toString() == Token.leftBracket } - val rightBrackets = count { it.toString() == Token.rightBracket } - val neededBrackets = leftBrackets - rightBrackets - return replace(Token.minusDisplay, Token.minus) - .plus(Token.rightBracket.repeat(neededBrackets.coerceAtLeast(0))) - } - init { - /** - * mxParser uses some unnecessary rounding for doubles. It causes expressions like 9999^9999 - * to load CPU very much. We use BigDecimal to achieve same result without CPU overload. - */ - MathParserLicense.iConfirmNonCommercialUse("Sad Ellie") - MathParser.setCanonicalRounding(false) - MathParser.removeBuiltinTokens("log") - MathParser.modifyBuiltinToken("lg", Token.log.dropLast(1)) - // Observe and invoke calculation without UI lag. viewModelScope.launch(Dispatchers.Default) { - combine(_userPrefs, textFieldController.input) { userPrefs, _ -> - if (userPrefs.radianMode) MathParser.setRadiansMode() else MathParser.setDegreesMode() - }.collectLatest { - calculateInput() + merge(_userPrefs, _input).collectLatest { + val calculated = calculateInput() + _output.update { + // Don't show error when simply entering stuff + if (calculated !is CalculationResult.Default) CalculationResult.Default() else calculated + } } } } 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 deleted file mode 100644 index cca32725..00000000 --- a/feature/calculator/src/main/java/com/sadellie/unitto/feature/calculator/TextFieldController.kt +++ /dev/null @@ -1,225 +0,0 @@ -/* - * Unitto is a unit converter for Android - * Copyright (c) 2023 Elshan Agaev - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package com.sadellie.unitto.feature.calculator - -import androidx.compose.ui.text.TextRange -import androidx.compose.ui.text.input.TextFieldValue -import com.sadellie.unitto.core.base.Token -import com.sadellie.unitto.core.base.Separator -import com.sadellie.unitto.core.ui.UnittoFormatter -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.update -import javax.inject.Inject -import kotlin.math.abs - -class TextFieldController @Inject constructor() { - var input: MutableStateFlow = MutableStateFlow(TextFieldValue()) - - // Internally we don't care about user preference here, because during composition this - // symbols will be replaced to those that user wanted. - // We do this because it adds unnecessary logic: it requires additional logic to observe and - // react to formatting preferences at this level. - private val localFormatter: UnittoFormatter by lazy { - UnittoFormatter().also { - it.setSeparator(Separator.COMMA) - } - } - - private val cursorFixer by lazy { CursorFixer() } - - fun addToInput(symbols: String) { - - val text = input.value.text - val selection = input.value.selection - val lastToEndDistance = text.length - selection.end - - val newInput = if (text.isEmpty()) { - symbols - } else { - text.replaceRange(selection.start, selection.end, symbols) - } - - val inputFormatted = newInput.fixFormat() - val newSelectionStartEnd = inputFormatted.length - lastToEndDistance - val fixedCursor = fixCursor( - newPosition = newSelectionStartEnd..newSelectionStartEnd, - currentInput = inputFormatted - ) ?: newSelectionStartEnd..newSelectionStartEnd - - input.update { - it.copy( - text = inputFormatted, - selection = TextRange(fixedCursor) - ) - } - } - - /** - * Method to call when pasting from clipbaord. It filters input before calling [addToInput]. - */ - fun pasteSymbols(symbols: String) = addToInput(symbols.filterUnknownSymbols()) - - fun moveCursor(newPosition: IntRange) { - input.update { - it.copy( - selection = TextRange(fixCursor(newPosition) ?: return) - ) - } - } - - fun delete() { - val selection = input.value.selection - val distanceFromEnd = input.value.text.length - selection.end - - val deleteRangeStart = when (selection.end) { - // Don't delete if at the start of the text field - 0 -> return - // We don't have anything selected (cursor in one position) - // like this 1234|56 => after deleting will be like this 123|56 - // Cursor moved one symbol left - selection.start -> { - // We default to 1 here. It means that cursor is not placed after illegal token - // Just a number or a binary operator or something else, can delete by one symbol - val amountOfSymbolsToDelete: Int = - cursorFixer.tokenLengthInFront(input.value.text, selection.end) ?: 1 - selection.start - amountOfSymbolsToDelete - } - // We have multiple symbols selected - // like this 123[45]6 => after deleting will be like this 123|6 - // Cursor will be placed where selection start was - else -> selection.start - } - - input.update { - val newText = it.text - .removeRange(deleteRangeStart, it.selection.end) - .fixFormat() - it.copy( - text = newText, - selection = TextRange((newText.length - distanceFromEnd).coerceAtLeast(0)) - ) - } - } - - fun clearInput() = input.update { TextFieldValue() } - - fun inputTextWithoutFormatting() = input.value.text - .replace(localFormatter.grouping, "") - .replace(localFormatter.fractional, Token.dot) - - private fun fixCursor( - newPosition: IntRange, - currentInput: String = input.value.text - ): IntRange? { - if (newPosition.last > currentInput.length) return null - - val fixedLeftCursor = cursorFixer.fixCursorIfNeeded(currentInput, newPosition.first) - val fixedRightCursor = cursorFixer.fixCursorIfNeeded(currentInput, newPosition.last) - - return fixedLeftCursor..fixedRightCursor - } - - private fun String.fixFormat(): String = localFormatter.reFormat(this) - - private fun String.filterUnknownSymbols() = localFormatter.filterUnknownSymbols(this) - - private fun TextRange(range: IntRange): TextRange = TextRange(range.first, range.last) - - inner class CursorFixer { - private val illegalTokens by lazy { - listOf( - Token.arSin, - Token.arCos, - Token.acTan, - Token.cos, - Token.sin, - Token.exp, - Token.ln, - Token.log, - Token.tan - ) - } - - fun fixCursorIfNeeded(str: String, pos: Int): Int { - // Best position if we move cursor left - val bestLeft = bestPositionLeft(str, pos) - // Best position if we move cursor right - val bestRight = bestPositionRight(str, pos) - - return listOf(bestLeft, bestRight) - .minBy { abs(it - pos) } - } - - fun tokenLengthInFront(str: String, pos: Int): Int? { - illegalTokens.forEach { - if (pos.afterToken(str, it)) return it.length - } - - return null - } - - private fun bestPositionLeft(str: String, pos: Int): Int { - var cursorPosition = pos - while (placedIllegally(str, cursorPosition)) cursorPosition-- - return cursorPosition - } - - private fun bestPositionRight(str: String, pos: Int): Int { - var cursorPosition = pos - while (placedIllegally(str, cursorPosition)) cursorPosition++ - return cursorPosition - } - - private fun placedIllegally(str: String, pos: Int): Boolean { - // For things like "123,|456" - this is illegal - if (pos.afterToken(str, localFormatter.grouping)) return true - - // For things like "123,456+c|os(8)" - this is illegal - illegalTokens.forEach { - if (pos.atToken(str, it)) return true - } - - return false - } - - /** - * Don't use if token is 1 symbol long, it wouldn't make sense! Use [afterToken] instead. - * @see [afterToken] - */ - private fun Int.atToken(str: String, token: String): Boolean { - val checkBound = (token.length - 1).coerceAtLeast(1) - - val stringToScan = str.substring( - startIndex = (this - checkBound).coerceAtLeast(0), - endIndex = (this + checkBound).coerceAtMost(str.length) - ) - - return stringToScan.contains(token) - } - - private fun Int.afterToken(str: String, token: String): Boolean { - val stringToScan = str.substring( - startIndex = (this - token.length).coerceAtLeast(0), - endIndex = this - ) - - return stringToScan.contains(token) - } - } -} 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 index 16d1f1a6..36068f37 100644 --- 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 @@ -52,7 +52,6 @@ import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import com.sadellie.unitto.core.base.Token -import com.sadellie.unitto.core.ui.Formatter import com.sadellie.unitto.core.ui.common.ColumnWithConstraints import com.sadellie.unitto.core.ui.common.KeyboardButtonAdditional import com.sadellie.unitto.core.ui.common.KeyboardButtonFilled @@ -103,6 +102,7 @@ import com.sadellie.unitto.core.ui.common.key.unittoicons.Tan internal fun CalculatorKeyboard( modifier: Modifier, radianMode: Boolean, + fractional: String, allowVibration: Boolean, addSymbol: (String) -> Unit, clearSymbols: () -> Unit, @@ -114,6 +114,7 @@ internal fun CalculatorKeyboard( PortraitKeyboard( modifier = modifier, radianMode = radianMode, + fractional = fractional, allowVibration = allowVibration, addSymbol = addSymbol, toggleAngleMode = toggleAngleMode, @@ -125,6 +126,7 @@ internal fun CalculatorKeyboard( LandscapeKeyboard( modifier = modifier, radianMode = radianMode, + fractional = fractional, allowVibration = allowVibration, addSymbol = addSymbol, toggleAngleMode = toggleAngleMode, @@ -139,6 +141,7 @@ internal fun CalculatorKeyboard( private fun PortraitKeyboard( modifier: Modifier, radianMode: Boolean, + fractional: String, allowVibration: Boolean, addSymbol: (String) -> Unit, toggleAngleMode: () -> Unit, @@ -146,7 +149,7 @@ private fun PortraitKeyboard( clearSymbols: () -> Unit, evaluate: () -> Unit ) { - val fractionalIcon = remember { if (Formatter.fractional == Token.dot) UnittoIcons.Dot else UnittoIcons.Comma } + val fractionalIcon = remember { if (fractional == Token.Digit.dot) UnittoIcons.Dot else UnittoIcons.Comma } var showAdditional: Boolean by remember { mutableStateOf(false) } var invMode: Boolean by remember { mutableStateOf(false) } val expandRotation: Float by animateFloatAsState( @@ -217,32 +220,32 @@ private fun PortraitKeyboard( Spacer(modifier = Modifier.height(verticalFraction(0.025f))) Row(weightModifier) { - KeyboardButtonFilled(mainButtonModifier, UnittoIcons.LeftBracket, allowVibration) { addSymbol(Token.leftBracket) } - KeyboardButtonFilled(mainButtonModifier, UnittoIcons.RightBracket, allowVibration) { addSymbol(Token.rightBracket) } - KeyboardButtonFilled(mainButtonModifier, UnittoIcons.Percent, allowVibration) { addSymbol(Token.percent) } - KeyboardButtonFilled(mainButtonModifier, UnittoIcons.Divide, allowVibration) { addSymbol(Token.divideDisplay) } + KeyboardButtonFilled(mainButtonModifier, UnittoIcons.LeftBracket, allowVibration) { addSymbol(Token.Operator.leftBracket) } + KeyboardButtonFilled(mainButtonModifier, UnittoIcons.RightBracket, allowVibration) { addSymbol(Token.Operator.rightBracket) } + KeyboardButtonFilled(mainButtonModifier, UnittoIcons.Percent, allowVibration) { addSymbol(Token.Operator.percent) } + KeyboardButtonFilled(mainButtonModifier, UnittoIcons.Divide, allowVibration) { addSymbol(Token.Operator.divide) } } Row(weightModifier) { - KeyboardButtonLight(mainButtonModifier, UnittoIcons.Key7, allowVibration) { addSymbol(Token._7) } - KeyboardButtonLight(mainButtonModifier, UnittoIcons.Key8, allowVibration) { addSymbol(Token._8) } - KeyboardButtonLight(mainButtonModifier, UnittoIcons.Key9, allowVibration) { addSymbol(Token._9) } - KeyboardButtonFilled(mainButtonModifier, UnittoIcons.Multiply, allowVibration) { addSymbol(Token.multiplyDisplay) } + KeyboardButtonLight(mainButtonModifier, UnittoIcons.Key7, allowVibration) { addSymbol(Token.Digit._7) } + KeyboardButtonLight(mainButtonModifier, UnittoIcons.Key8, allowVibration) { addSymbol(Token.Digit._8) } + KeyboardButtonLight(mainButtonModifier, UnittoIcons.Key9, allowVibration) { addSymbol(Token.Digit._9) } + KeyboardButtonFilled(mainButtonModifier, UnittoIcons.Multiply, allowVibration) { addSymbol(Token.Operator.multiply) } } Row(weightModifier) { - KeyboardButtonLight(mainButtonModifier, UnittoIcons.Key4, allowVibration) { addSymbol(Token._4) } - KeyboardButtonLight(mainButtonModifier, UnittoIcons.Key5, allowVibration) { addSymbol(Token._5) } - KeyboardButtonLight(mainButtonModifier, UnittoIcons.Key6, allowVibration) { addSymbol(Token._6) } - KeyboardButtonFilled(mainButtonModifier, UnittoIcons.Minus, allowVibration) { addSymbol(Token.minusDisplay) } + KeyboardButtonLight(mainButtonModifier, UnittoIcons.Key4, allowVibration) { addSymbol(Token.Digit._4) } + KeyboardButtonLight(mainButtonModifier, UnittoIcons.Key5, allowVibration) { addSymbol(Token.Digit._5) } + KeyboardButtonLight(mainButtonModifier, UnittoIcons.Key6, allowVibration) { addSymbol(Token.Digit._6) } + KeyboardButtonFilled(mainButtonModifier, UnittoIcons.Minus, allowVibration) { addSymbol(Token.Operator.minus) } } Row(weightModifier) { - KeyboardButtonLight(mainButtonModifier, UnittoIcons.Key1, allowVibration) { addSymbol(Token._1) } - KeyboardButtonLight(mainButtonModifier, UnittoIcons.Key2, allowVibration) { addSymbol(Token._2) } - KeyboardButtonLight(mainButtonModifier, UnittoIcons.Key3, allowVibration) { addSymbol(Token._3) } - KeyboardButtonFilled(mainButtonModifier, UnittoIcons.Plus, allowVibration) { addSymbol(Token.plus) } + KeyboardButtonLight(mainButtonModifier, UnittoIcons.Key1, allowVibration) { addSymbol(Token.Digit._1) } + KeyboardButtonLight(mainButtonModifier, UnittoIcons.Key2, allowVibration) { addSymbol(Token.Digit._2) } + KeyboardButtonLight(mainButtonModifier, UnittoIcons.Key3, allowVibration) { addSymbol(Token.Digit._3) } + KeyboardButtonFilled(mainButtonModifier, UnittoIcons.Plus, allowVibration) { addSymbol(Token.Operator.plus) } } Row(weightModifier) { - KeyboardButtonLight(mainButtonModifier, UnittoIcons.Key0, allowVibration) { addSymbol(Token._0) } - KeyboardButtonLight(mainButtonModifier, fractionalIcon, allowVibration) { addSymbol(Token.dot) } + KeyboardButtonLight(mainButtonModifier, UnittoIcons.Key0, allowVibration) { addSymbol(Token.Digit._0) } + KeyboardButtonLight(mainButtonModifier, fractionalIcon, allowVibration) { addSymbol(Token.Digit.dot) } KeyboardButtonLight(mainButtonModifier, UnittoIcons.Backspace, allowVibration, clearSymbols) { deleteSymbol() } KeyboardButtonFilled(mainButtonModifier, UnittoIcons.Equal, allowVibration) { evaluate() } } @@ -263,24 +266,24 @@ private fun AdditionalButtonsPortrait( ) { Column { Row { - KeyboardButtonAdditional(modifier, UnittoIcons.SquareRootWide, allowVibration) { addSymbol(Token.sqrt) } - KeyboardButtonAdditional(modifier, UnittoIcons.Pi, allowVibration) { addSymbol(Token.pi) } - KeyboardButtonAdditional(modifier, UnittoIcons.ExponentWide, allowVibration) { addSymbol(Token.exponent) } - KeyboardButtonAdditional(modifier, UnittoIcons.Factorial, allowVibration) { addSymbol(Token.factorial) } + KeyboardButtonAdditional(modifier, UnittoIcons.SquareRootWide, allowVibration) { addSymbol(Token.Operator.sqrt) } + KeyboardButtonAdditional(modifier, UnittoIcons.Pi, allowVibration) { addSymbol(Token.Const.pi) } + KeyboardButtonAdditional(modifier, UnittoIcons.ExponentWide, allowVibration) { addSymbol(Token.Operator.power) } + KeyboardButtonAdditional(modifier, UnittoIcons.Factorial, allowVibration) { addSymbol(Token.Operator.factorial) } } AnimatedVisibility(showAdditional) { Column { Row { KeyboardButtonAdditional(modifier, if (radianMode) UnittoIcons.Rad else UnittoIcons.Deg, allowVibration) { toggleAngleMode() } - KeyboardButtonAdditional(modifier, UnittoIcons.Sin, allowVibration) { addSymbol(Token.sin) } - KeyboardButtonAdditional(modifier, UnittoIcons.Cos, allowVibration) { addSymbol(Token.cos) } - KeyboardButtonAdditional(modifier, UnittoIcons.Tan, allowVibration) { addSymbol(Token.tan) } + KeyboardButtonAdditional(modifier, UnittoIcons.Sin, allowVibration) { addSymbol(Token.Func.sinBracket) } + KeyboardButtonAdditional(modifier, UnittoIcons.Cos, allowVibration) { addSymbol(Token.Func.cosBracket) } + KeyboardButtonAdditional(modifier, UnittoIcons.Tan, allowVibration) { addSymbol(Token.Func.tanBracket) } } Row { KeyboardButtonAdditional(modifier, UnittoIcons.Inv, allowVibration) { toggleInvMode() } - KeyboardButtonAdditional(modifier, UnittoIcons.E, allowVibration) { addSymbol(Token.e) } - KeyboardButtonAdditional(modifier, UnittoIcons.Ln, allowVibration) { addSymbol(Token.ln) } - KeyboardButtonAdditional(modifier, UnittoIcons.Log, allowVibration) { addSymbol(Token.log) } + KeyboardButtonAdditional(modifier, UnittoIcons.E, allowVibration) { addSymbol(Token.Const.e) } + KeyboardButtonAdditional(modifier, UnittoIcons.Ln, allowVibration) { addSymbol(Token.Func.lnBracket) } + KeyboardButtonAdditional(modifier, UnittoIcons.Log, allowVibration) { addSymbol(Token.Func.logBracket) } } } } @@ -299,24 +302,24 @@ private fun AdditionalButtonsPortraitInverse( ) { Column { Row { - KeyboardButtonAdditional(modifier, UnittoIcons.Modulo, allowVibration) { addSymbol(Token.modulo) } - KeyboardButtonAdditional(modifier, UnittoIcons.Pi, allowVibration) { addSymbol(Token.pi) } - KeyboardButtonAdditional(modifier, UnittoIcons.ExponentWide, allowVibration) { addSymbol(Token.exponent) } - KeyboardButtonAdditional(modifier, UnittoIcons.Factorial, allowVibration) { addSymbol(Token.factorial) } + KeyboardButtonAdditional(modifier, UnittoIcons.Modulo, allowVibration) { addSymbol(Token.Operator.modulo) } + KeyboardButtonAdditional(modifier, UnittoIcons.Pi, allowVibration) { addSymbol(Token.Const.pi) } + KeyboardButtonAdditional(modifier, UnittoIcons.ExponentWide, allowVibration) { addSymbol(Token.Operator.power) } + KeyboardButtonAdditional(modifier, UnittoIcons.Factorial, allowVibration) { addSymbol(Token.Operator.factorial) } } AnimatedVisibility(showAdditional) { Column { Row { KeyboardButtonAdditional(modifier, if (radianMode) UnittoIcons.Rad else UnittoIcons.Deg, allowVibration) { toggleAngleMode() } - KeyboardButtonAdditional(modifier, UnittoIcons.ArSin, allowVibration) { addSymbol(Token.arSin) } - KeyboardButtonAdditional(modifier, UnittoIcons.ArCos, allowVibration) { addSymbol(Token.arCos) } - KeyboardButtonAdditional(modifier, UnittoIcons.AcTan, allowVibration) { addSymbol(Token.acTan) } + KeyboardButtonAdditional(modifier, UnittoIcons.ArSin, allowVibration) { addSymbol(Token.Func.arsinBracket) } + KeyboardButtonAdditional(modifier, UnittoIcons.ArCos, allowVibration) { addSymbol(Token.Func.arcosBracket) } + KeyboardButtonAdditional(modifier, UnittoIcons.AcTan, allowVibration) { addSymbol(Token.Func.actanBracket) } } Row { KeyboardButtonAdditional(modifier, UnittoIcons.Inv, allowVibration) { toggleInvMode() } - KeyboardButtonAdditional(modifier, UnittoIcons.E, allowVibration) { addSymbol(Token.e) } - KeyboardButtonAdditional(modifier, UnittoIcons.Exp, allowVibration) { addSymbol(Token.exp) } - KeyboardButtonAdditional(modifier, UnittoIcons.Log, allowVibration) { addSymbol(Token.log) } + KeyboardButtonAdditional(modifier, UnittoIcons.E, allowVibration) { addSymbol(Token.Const.e) } + KeyboardButtonAdditional(modifier, UnittoIcons.Exp, allowVibration) { addSymbol(Token.Func.expBracket) } + KeyboardButtonAdditional(modifier, UnittoIcons.Log, allowVibration) { addSymbol(Token.Func.logBracket) } } } } @@ -327,6 +330,7 @@ private fun AdditionalButtonsPortraitInverse( private fun LandscapeKeyboard( modifier: Modifier, radianMode: Boolean, + fractional: String, allowVibration: Boolean, addSymbol: (String) -> Unit, toggleAngleMode: () -> Unit, @@ -334,7 +338,7 @@ private fun LandscapeKeyboard( clearSymbols: () -> Unit, evaluate: () -> Unit ) { - val fractionalIcon = remember { if (Formatter.fractional == Token.dot) UnittoIcons.Dot else UnittoIcons.Comma } + val fractionalIcon = remember { if (fractional == Token.Digit.dot) UnittoIcons.Dot else UnittoIcons.Comma } var invMode: Boolean by remember { mutableStateOf(false) } RowWithConstraints(modifier) { constraints -> @@ -370,34 +374,34 @@ private fun LandscapeKeyboard( } Column(Modifier.weight(1f)) { - KeyboardButtonLight(buttonModifier, UnittoIcons.Key7, allowVibration) { addSymbol(Token._7) } - KeyboardButtonLight(buttonModifier, UnittoIcons.Key4, allowVibration) { addSymbol(Token._4) } - KeyboardButtonLight(buttonModifier, UnittoIcons.Key1, allowVibration) { addSymbol(Token._1) } - KeyboardButtonLight(buttonModifier, UnittoIcons.Key0, allowVibration) { addSymbol(Token._0) } + KeyboardButtonLight(buttonModifier, UnittoIcons.Key7, allowVibration) { addSymbol(Token.Digit._7) } + KeyboardButtonLight(buttonModifier, UnittoIcons.Key4, allowVibration) { addSymbol(Token.Digit._4) } + KeyboardButtonLight(buttonModifier, UnittoIcons.Key1, allowVibration) { addSymbol(Token.Digit._1) } + KeyboardButtonLight(buttonModifier, UnittoIcons.Key0, allowVibration) { addSymbol(Token.Digit._0) } } Column(Modifier.weight(1f)) { - KeyboardButtonLight(buttonModifier, UnittoIcons.Key8, allowVibration) { addSymbol(Token._8) } - KeyboardButtonLight(buttonModifier, UnittoIcons.Key5, allowVibration) { addSymbol(Token._5) } - KeyboardButtonLight(buttonModifier, UnittoIcons.Key2, allowVibration) { addSymbol(Token._2) } - KeyboardButtonLight(buttonModifier, fractionalIcon, allowVibration) { addSymbol(Token.dot) } + KeyboardButtonLight(buttonModifier, UnittoIcons.Key8, allowVibration) { addSymbol(Token.Digit._8) } + KeyboardButtonLight(buttonModifier, UnittoIcons.Key5, allowVibration) { addSymbol(Token.Digit._5) } + KeyboardButtonLight(buttonModifier, UnittoIcons.Key2, allowVibration) { addSymbol(Token.Digit._2) } + KeyboardButtonLight(buttonModifier, fractionalIcon, allowVibration) { addSymbol(Token.Digit.dot) } } Column(Modifier.weight(1f)) { - KeyboardButtonLight(buttonModifier, UnittoIcons.Key9, allowVibration) { addSymbol(Token._9) } - KeyboardButtonLight(buttonModifier, UnittoIcons.Key6, allowVibration) { addSymbol(Token._6) } - KeyboardButtonLight(buttonModifier, UnittoIcons.Key3, allowVibration) { addSymbol(Token._3) } + KeyboardButtonLight(buttonModifier, UnittoIcons.Key9, allowVibration) { addSymbol(Token.Digit._9) } + KeyboardButtonLight(buttonModifier, UnittoIcons.Key6, allowVibration) { addSymbol(Token.Digit._6) } + KeyboardButtonLight(buttonModifier, UnittoIcons.Key3, allowVibration) { addSymbol(Token.Digit._3) } KeyboardButtonLight(buttonModifier, UnittoIcons.Backspace, allowVibration, clearSymbols) { deleteSymbol() } } Column(Modifier.weight(1f)) { - KeyboardButtonFilled(buttonModifier, UnittoIcons.LeftBracket, allowVibration) { addSymbol(Token.leftBracket) } - KeyboardButtonFilled(buttonModifier, UnittoIcons.Multiply, allowVibration) { addSymbol(Token.multiplyDisplay) } - KeyboardButtonFilled(buttonModifier, UnittoIcons.Minus, allowVibration) { addSymbol(Token.minusDisplay) } - KeyboardButtonFilled(buttonModifier, UnittoIcons.Plus, allowVibration) { addSymbol(Token.plus) } + KeyboardButtonFilled(buttonModifier, UnittoIcons.LeftBracket, allowVibration) { addSymbol(Token.Operator.leftBracket) } + KeyboardButtonFilled(buttonModifier, UnittoIcons.Multiply, allowVibration) { addSymbol(Token.Operator.multiply) } + KeyboardButtonFilled(buttonModifier, UnittoIcons.Minus, allowVibration) { addSymbol(Token.Operator.minus) } + KeyboardButtonFilled(buttonModifier, UnittoIcons.Plus, allowVibration) { addSymbol(Token.Operator.plus) } } Column(Modifier.weight(1f)) { - KeyboardButtonFilled(buttonModifier, UnittoIcons.RightBracket, allowVibration) { addSymbol(Token.rightBracket) } - KeyboardButtonFilled(buttonModifier, UnittoIcons.Divide, allowVibration) { addSymbol(Token.divideDisplay) } - KeyboardButtonFilled(buttonModifier, UnittoIcons.Percent, allowVibration) { addSymbol(Token.percent) } + KeyboardButtonFilled(buttonModifier, UnittoIcons.RightBracket, allowVibration) { addSymbol(Token.Operator.rightBracket) } + KeyboardButtonFilled(buttonModifier, UnittoIcons.Divide, allowVibration) { addSymbol(Token.Operator.divide) } + KeyboardButtonFilled(buttonModifier, UnittoIcons.Percent, allowVibration) { addSymbol(Token.Operator.percent) } KeyboardButtonFilled(buttonModifier, UnittoIcons.Equal, allowVibration) { evaluate() } } } @@ -416,22 +420,22 @@ private fun AdditionalButtonsLandscape( Column(modifier) { KeyboardButtonAdditional(buttonModifier, if (radianMode) UnittoIcons.Rad else UnittoIcons.Deg, allowVibration) { toggleAngleMode() } KeyboardButtonAdditional(buttonModifier, UnittoIcons.Inv, allowVibration) { toggleInvMode() } - KeyboardButtonAdditional(buttonModifier, UnittoIcons.Sin, allowVibration) { addSymbol(Token.sin) } - KeyboardButtonAdditional(buttonModifier, UnittoIcons.E, allowVibration) { addSymbol(Token.e) } + KeyboardButtonAdditional(buttonModifier, UnittoIcons.Sin, allowVibration) { addSymbol(Token.Func.sinBracket) } + KeyboardButtonAdditional(buttonModifier, UnittoIcons.E, allowVibration) { addSymbol(Token.Const.e) } } Column(modifier) { - KeyboardButtonAdditional(buttonModifier, UnittoIcons.SquareRootWide, allowVibration) { addSymbol(Token.sqrt) } - KeyboardButtonAdditional(buttonModifier, UnittoIcons.ExponentWide, allowVibration) { addSymbol(Token.exponent) } - KeyboardButtonAdditional(buttonModifier, UnittoIcons.Cos, allowVibration) { addSymbol(Token.cos) } - KeyboardButtonAdditional(buttonModifier, UnittoIcons.Ln, allowVibration) { addSymbol(Token.ln) } + KeyboardButtonAdditional(buttonModifier, UnittoIcons.SquareRootWide, allowVibration) { addSymbol(Token.Operator.sqrt) } + KeyboardButtonAdditional(buttonModifier, UnittoIcons.ExponentWide, allowVibration) { addSymbol(Token.Operator.power) } + KeyboardButtonAdditional(buttonModifier, UnittoIcons.Cos, allowVibration) { addSymbol(Token.Func.cosBracket) } + KeyboardButtonAdditional(buttonModifier, UnittoIcons.Ln, allowVibration) { addSymbol(Token.Func.lnBracket) } } Column(modifier) { - KeyboardButtonAdditional(buttonModifier, UnittoIcons.Pi, allowVibration) { addSymbol(Token.pi) } - KeyboardButtonAdditional(buttonModifier, UnittoIcons.Factorial, allowVibration) { addSymbol(Token.factorial) } - KeyboardButtonAdditional(buttonModifier, UnittoIcons.Tan, allowVibration) { addSymbol(Token.tan) } - KeyboardButtonAdditional(buttonModifier, UnittoIcons.Log, allowVibration) { addSymbol(Token.log) } + KeyboardButtonAdditional(buttonModifier, UnittoIcons.Pi, allowVibration) { addSymbol(Token.Const.pi) } + KeyboardButtonAdditional(buttonModifier, UnittoIcons.Factorial, allowVibration) { addSymbol(Token.Operator.factorial) } + KeyboardButtonAdditional(buttonModifier, UnittoIcons.Tan, allowVibration) { addSymbol(Token.Func.tanBracket) } + KeyboardButtonAdditional(buttonModifier, UnittoIcons.Log, allowVibration) { addSymbol(Token.Func.logBracket) } } } @@ -448,22 +452,22 @@ private fun AdditionalButtonsLandscapeInverse( Column(modifier) { KeyboardButtonAdditional(buttonModifier, if (radianMode) UnittoIcons.Rad else UnittoIcons.Deg, allowVibration) { toggleAngleMode() } KeyboardButtonAdditional(buttonModifier, UnittoIcons.Inv, allowVibration) { toggleInvMode() } - KeyboardButtonAdditional(buttonModifier, UnittoIcons.ArSin, allowVibration) { addSymbol(Token.arSin) } - KeyboardButtonAdditional(buttonModifier, UnittoIcons.E, allowVibration) { addSymbol(Token.e) } + KeyboardButtonAdditional(buttonModifier, UnittoIcons.ArSin, allowVibration) { addSymbol(Token.Func.arsinBracket) } + KeyboardButtonAdditional(buttonModifier, UnittoIcons.E, allowVibration) { addSymbol(Token.Const.e) } } Column(modifier) { - KeyboardButtonAdditional(buttonModifier, UnittoIcons.Modulo, allowVibration) { addSymbol(Token.modulo) } - KeyboardButtonAdditional(buttonModifier, UnittoIcons.ExponentWide, allowVibration) { addSymbol(Token.exponent) } - KeyboardButtonAdditional(buttonModifier, UnittoIcons.ArCos, allowVibration) { addSymbol(Token.arCos) } - KeyboardButtonAdditional(buttonModifier, UnittoIcons.Exp, allowVibration) { addSymbol(Token.exp) } + KeyboardButtonAdditional(buttonModifier, UnittoIcons.Modulo, allowVibration) { addSymbol(Token.Operator.modulo) } + KeyboardButtonAdditional(buttonModifier, UnittoIcons.ExponentWide, allowVibration) { addSymbol(Token.Operator.power) } + KeyboardButtonAdditional(buttonModifier, UnittoIcons.ArCos, allowVibration) { addSymbol(Token.Func.arcosBracket) } + KeyboardButtonAdditional(buttonModifier, UnittoIcons.Exp, allowVibration) { addSymbol(Token.Func.expBracket) } } Column(modifier) { - KeyboardButtonAdditional(buttonModifier, UnittoIcons.Pi, allowVibration) { addSymbol(Token.pi) } - KeyboardButtonAdditional(buttonModifier, UnittoIcons.Factorial, allowVibration) { addSymbol(Token.factorial) } - KeyboardButtonAdditional(buttonModifier, UnittoIcons.AcTan, allowVibration) { addSymbol(Token.acTan) } - KeyboardButtonAdditional(buttonModifier, UnittoIcons.Log, allowVibration) { addSymbol(Token.log) } + KeyboardButtonAdditional(buttonModifier, UnittoIcons.Pi, allowVibration) { addSymbol(Token.Const.pi) } + KeyboardButtonAdditional(buttonModifier, UnittoIcons.Factorial, allowVibration) { addSymbol(Token.Operator.factorial) } + KeyboardButtonAdditional(buttonModifier, UnittoIcons.AcTan, allowVibration) { addSymbol(Token.Func.actanBracket) } + KeyboardButtonAdditional(buttonModifier, UnittoIcons.Log, allowVibration) { addSymbol(Token.Func.logBracket) } } } @@ -473,6 +477,7 @@ private fun PreviewCalculatorKeyboard() { CalculatorKeyboard( modifier = Modifier, radianMode = true, + fractional = ".", addSymbol = {}, clearSymbols = {}, deleteSymbol = {}, 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 071cf31e..8173b03a 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 @@ -20,6 +20,8 @@ package com.sadellie.unitto.feature.calculator.components import androidx.compose.foundation.background import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.PressInteraction import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize @@ -36,6 +38,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -54,20 +57,23 @@ 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 com.sadellie.unitto.core.ui.Formatter +import com.sadellie.unitto.core.ui.common.textfield.ExpressionTransformer +import com.sadellie.unitto.core.ui.common.textfield.FormatterSymbols import com.sadellie.unitto.core.ui.common.textfield.UnittoTextToolbar import com.sadellie.unitto.core.ui.common.textfield.copyWithoutGrouping import com.sadellie.unitto.core.ui.theme.NumbersTextStyleDisplayMedium import com.sadellie.unitto.data.model.HistoryItem import com.sadellie.unitto.feature.calculator.R import java.text.SimpleDateFormat -import java.util.* +import java.util.Locale @Composable internal fun HistoryList( modifier: Modifier, historyItems: List, historyItemHeightCallback: (Int) -> Unit, + formatterSymbols: FormatterSymbols, + addTokens: (String) -> Unit, ) { val verticalArrangement by remember(historyItems) { derivedStateOf { @@ -103,13 +109,17 @@ internal fun HistoryList( item { HistoryListItem( modifier = Modifier.onPlaced { historyItemHeightCallback(it.size.height) }, - historyItem = historyItems.first() + historyItem = historyItems.first(), + formatterSymbols = formatterSymbols, + addTokens = addTokens, ) } items(historyItems.drop(1)) { historyItem -> HistoryListItem( modifier = Modifier, - historyItem = historyItem + historyItem = historyItem, + formatterSymbols = formatterSymbols, + addTokens = addTokens, ) } } @@ -120,23 +130,42 @@ internal fun HistoryList( private fun HistoryListItem( modifier: Modifier = Modifier, historyItem: HistoryItem, + formatterSymbols: FormatterSymbols, + addTokens: (String) -> Unit, ) { val clipboardManager = LocalClipboardManager.current - val expression = Formatter.format(historyItem.expression) + val expression = historyItem.expression var expressionValue by remember(expression) { mutableStateOf(TextFieldValue(expression, TextRange(expression.length))) } - val result = Formatter.format(historyItem.result) + val result = historyItem.result var resultValue by remember(result) { mutableStateOf(TextFieldValue(result, TextRange(result.length))) } + val expressionInteractionSource = remember(expression) { MutableInteractionSource() } + LaunchedEffect(expressionInteractionSource) { + expressionInteractionSource.interactions.collect { + if (it is PressInteraction.Release) addTokens(expression) + } + } + + val resultInteractionSource = remember(result) { MutableInteractionSource() } + LaunchedEffect(resultInteractionSource) { + resultInteractionSource.interactions.collect { + if (it is PressInteraction.Release) addTokens(result) + } + } + Column(modifier = modifier) { CompositionLocalProvider( LocalTextInputService provides null, LocalTextToolbar provides UnittoTextToolbar( view = LocalView.current, - copyCallback = { clipboardManager.copyWithoutGrouping(expressionValue) } + copyCallback = { + clipboardManager.copyWithoutGrouping(expressionValue, formatterSymbols) + expressionValue = expressionValue.copy(selection = TextRange(expressionValue.selection.end)) + } ) ) { BasicTextField( @@ -148,7 +177,9 @@ private fun HistoryListItem( .padding(horizontal = 8.dp) .horizontalScroll(rememberScrollState(), reverseScrolling = true), textStyle = NumbersTextStyleDisplayMedium.copy(color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.End), - readOnly = true + readOnly = true, + visualTransformation = ExpressionTransformer(formatterSymbols), + interactionSource = expressionInteractionSource ) } @@ -156,7 +187,10 @@ private fun HistoryListItem( LocalTextInputService provides null, LocalTextToolbar provides UnittoTextToolbar( view = LocalView.current, - copyCallback = { clipboardManager.copyWithoutGrouping(resultValue) } + copyCallback = { + clipboardManager.copyWithoutGrouping(resultValue, formatterSymbols) + resultValue = resultValue.copy(selection = TextRange(resultValue.selection.end)) + } ) ) { BasicTextField( @@ -168,7 +202,9 @@ private fun HistoryListItem( .padding(horizontal = 8.dp) .horizontalScroll(rememberScrollState(), reverseScrolling = true), textStyle = NumbersTextStyleDisplayMedium.copy(color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), textAlign = TextAlign.End), - readOnly = true + readOnly = true, + visualTransformation = ExpressionTransformer(formatterSymbols), + interactionSource = resultInteractionSource ) } } @@ -200,6 +236,9 @@ private fun PreviewHistoryList() { modifier = Modifier .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) .fillMaxSize(), - historyItems = historyItems - ) {} + historyItems = historyItems, + formatterSymbols = FormatterSymbols.Spaces, + historyItemHeightCallback = {}, + addTokens = {} + ) } 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 deleted file mode 100644 index ee4d727c..00000000 --- a/feature/calculator/src/test/java/com/sadellie/unitto/feature/calculator/TextFieldControllerTest.kt +++ /dev/null @@ -1,249 +0,0 @@ -/* - * Unitto is a unit converter for Android - * Copyright (c) 2023 Elshan Agaev - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package com.sadellie.unitto.feature.calculator - -import com.sadellie.unitto.core.base.Separator -import com.sadellie.unitto.core.ui.Formatter -import org.junit.Assert.assertEquals -import org.junit.Before -import org.junit.Test - -internal class TextFieldControllerTest { - private lateinit var textFieldController: TextFieldController - - private val TextFieldController.text: String - get() = this.input.value.text - - private val TextFieldController.selection: IntRange - get() = this.input.value.selection.start..this.input.value.selection.end - - @Before - fun setUp() { - textFieldController = TextFieldController() - Formatter.setSeparator(Separator.COMMA) - } - - @Test - fun `Add when empty`() { - // Add one symbol - textFieldController.addToInput("1") - assertEquals("1", textFieldController.text) - assertEquals(1..1, textFieldController.selection) - textFieldController.clearInput() - - // Add multiple - textFieldController.addToInput("123") - assertEquals("123", textFieldController.text) - assertEquals(3..3, textFieldController.selection) - textFieldController.clearInput() - - // Add multiple - textFieldController.addToInput("1234") - assertEquals("1,234", textFieldController.text) - assertEquals(5..5, textFieldController.selection) - textFieldController.clearInput() - - // Add multiple - textFieldController.addToInput("123456.789") - assertEquals("123,456.789", textFieldController.text) - assertEquals(11..11, textFieldController.selection) - textFieldController.clearInput() - } - - @Test - fun `Add when not empty one symbol at a time (check formatting)`() { - // Should be 1| - textFieldController.addToInput("1") - assertEquals("1", textFieldController.text) - assertEquals(1..1, textFieldController.selection) - - // Should be 12| - textFieldController.addToInput("2") - assertEquals("12", textFieldController.text) - assertEquals(2..2, textFieldController.selection) - - // Should be 123| - textFieldController.addToInput("3") - assertEquals("123", textFieldController.text) - assertEquals(3..3, textFieldController.selection) - - // Should be 1,234| - textFieldController.addToInput("4") - assertEquals("1,234", textFieldController.text) - assertEquals(5..5, textFieldController.selection) - - // Should be 12,345| - textFieldController.addToInput("5") - assertEquals("12,345", textFieldController.text) - assertEquals(6..6, textFieldController.selection) - } - - @Test - fun `Delete on empty input`() { - // Delete on empty input - textFieldController.delete() - assertEquals("", textFieldController.text) - assertEquals(0..0, textFieldController.selection) - textFieldController.clearInput() - } - - @Test - fun `Delete last remaining symbol`() { - textFieldController.addToInput("1") - textFieldController.delete() - assertEquals("", textFieldController.text) - assertEquals(0..0, textFieldController.selection) - textFieldController.clearInput() - } - - @Test - fun `Delete by one symbol (check formatting)`() { - textFieldController.addToInput("123456") - // Input is formatted into 123,456 - textFieldController.delete() - assertEquals("12,345", textFieldController.text) - assertEquals(6..6, textFieldController.selection) - textFieldController.delete() - assertEquals("1,234", textFieldController.text) - assertEquals(5..5, textFieldController.selection) - textFieldController.delete() - assertEquals("123", textFieldController.text) - println("in 123: ${textFieldController.selection}") - assertEquals(3..3, textFieldController.selection) - textFieldController.clearInput() - } - - @Test - fun `Delete multiple symbols, selected before separator`() { - textFieldController.addToInput("123789456") - // Input is formatted to 123,789,456 - textFieldController.moveCursor(3..7) - textFieldController.delete() - assertEquals("123,456", textFieldController.text) - assertEquals(3..3, textFieldController.selection) - textFieldController.clearInput() - } - - @Test - fun `Delete multiple symbols, selected not near separator`() { - textFieldController.addToInput("123789456") - // Input is formatted to 123,789,456 - textFieldController.moveCursor(3..9) - textFieldController.delete() - assertEquals("12,356", textFieldController.text) - assertEquals(4..4, textFieldController.selection) - textFieldController.clearInput() - } - - @Test - fun `Delete multiple symbols in weird input`() { - textFieldController.addToInput("123...789456") - // Input is formatted to 123...789456 - textFieldController.moveCursor(3..9) - textFieldController.delete() - assertEquals(4..4, textFieldController.selection) - assertEquals("123,456", textFieldController.text) - textFieldController.clearInput() - } - - @Test - fun `Delete illegal token when cursor is placed after it`() { - textFieldController.addToInput("cos(sin(ln(log(tan(") - textFieldController.delete() - assertEquals("cos(sin(ln(log(", textFieldController.text) - assertEquals(15..15, textFieldController.selection) - - textFieldController.delete() - assertEquals("cos(sin(ln(", textFieldController.text) - assertEquals(11..11, textFieldController.selection) - - textFieldController.delete() - assertEquals("cos(sin(", textFieldController.text) - assertEquals(8..8, textFieldController.selection) - - textFieldController.delete() - assertEquals("cos(", textFieldController.text) - assertEquals(4..4, textFieldController.selection) - - textFieldController.delete() - assertEquals("", textFieldController.text) - assertEquals(0..0, textFieldController.selection) - - textFieldController.addToInput("1234") - // Place cursor like 1|,234 - textFieldController.moveCursor(1..1) - textFieldController.delete() - assertEquals("234", textFieldController.text) - assertEquals(0..0, textFieldController.selection) - } - - @Test - fun `Place cursor illegally`() { - textFieldController.addToInput("123456.789") - // Input is 123,456.789 - textFieldController.moveCursor(4..4) - // Cursor should be placed like this 123|,456.789 - assertEquals(3..3, textFieldController.selection) - textFieldController.clearInput() - - textFieldController.addToInput("123456.789+cos(") - // Input is 123,456.789+cos( - textFieldController.moveCursor(13..13) - // Cursor should be placed like this 123,456.789+c|os( - assertEquals(12..12, textFieldController.selection) - textFieldController.clearInput() - } - - @Test - fun `get clear input text without formatting`() { - textFieldController.addToInput("123456.789+cos(..)") - // Input is 123,456.789 - assertEquals("123456.789+cos(..)", textFieldController.inputTextWithoutFormatting()) - } - - @Test - fun `Paste completely weird stuff`() { - textFieldController.pasteSymbols("crazy stuff from clipboard") - assertEquals("", textFieldController.text) - } - - @Test - fun `Paste partially weird stuff`() { - textFieldController.pasteSymbols("some crazy stuff cos(8+9)*7= that user may have in clipboard") - assertEquals("ecos(8+9)×7ee", textFieldController.text) - } - - @Test - fun `Paste acceptable stuff that needs symbol replacement`() { - textFieldController.pasteSymbols("cos(8+9)*7") - assertEquals("cos(8+9)×7", textFieldController.text) - } - - @Test - fun `Paste acceptable stuff that does not need replacement`() { - textFieldController.pasteSymbols("cos(8+9)×7") - assertEquals("cos(8+9)×7", textFieldController.text) - } - - @Test - fun `Paste nothing`() { - textFieldController.pasteSymbols("") - assertEquals("", textFieldController.text) - } -} diff --git a/feature/converter/build.gradle.kts b/feature/converter/build.gradle.kts index 3a94684b..22ab1a5b 100644 --- a/feature/converter/build.gradle.kts +++ b/feature/converter/build.gradle.kts @@ -36,7 +36,6 @@ dependencies { kapt(libs.androidx.room.compiler) testImplementation(libs.androidx.datastore) - implementation(libs.com.github.sadellie.exprk) implementation(libs.com.github.sadellie.themmo) implementation(libs.com.squareup.moshi) implementation(libs.com.squareup.retrofit2) @@ -46,4 +45,5 @@ dependencies { implementation(project(mapOf("path" to ":data:model"))) implementation(project(mapOf("path" to ":data:userprefs"))) implementation(project(mapOf("path" to ":data:units"))) + implementation(project(mapOf("path" to ":data:evaluatto"))) } diff --git a/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/ConverterScreen.kt b/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/ConverterScreen.kt index 6ec8c0be..cf195c17 100644 --- a/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/ConverterScreen.kt +++ b/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/ConverterScreen.kt @@ -30,9 +30,9 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.tooling.preview.PreviewParameter -import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.sadellie.unitto.core.ui.R @@ -46,11 +46,11 @@ import com.sadellie.unitto.feature.converter.components.TopScreenPart internal fun ConverterRoute( viewModel: ConverterViewModel = hiltViewModel(), navigateToLeftScreen: (String) -> Unit, - navigateToRightScreen: (unitFrom: String, unitTo: String, input: String) -> Unit, + navigateToRightScreen: (unitFrom: String, unitTo: String, input: String?) -> Unit, navigateToMenu: () -> Unit, navigateToSettings: () -> Unit ) { - val uiState = viewModel.uiStateFlow.collectAsStateWithLifecycle() + val uiState = viewModel.uiState.collectAsStateWithLifecycle() ConverterScreen( uiState = uiState.value, @@ -59,9 +59,11 @@ internal fun ConverterRoute( navigateToSettings = navigateToSettings, navigateToMenu = navigateToMenu, swapMeasurements = viewModel::swapUnits, - processInput = viewModel::processInput, - deleteDigit = viewModel::deleteDigit, + processInput = viewModel::addTokens, + deleteDigit = viewModel::deleteTokens, clearInput = viewModel::clearInput, + onCursorChange = viewModel::onCursorChange, + cutCallback = viewModel::deleteTokens, ) } @@ -69,13 +71,15 @@ internal fun ConverterRoute( private fun ConverterScreen( uiState: ConverterUIState, navigateToLeftScreen: (String) -> Unit, - navigateToRightScreen: (unitFrom: String, unitTo: String, input: String) -> Unit, + navigateToRightScreen: (unitFrom: String, unitTo: String, input: String?) -> Unit, navigateToSettings: () -> Unit, navigateToMenu: () -> Unit, swapMeasurements: () -> Unit, processInput: (String) -> Unit, deleteDigit: () -> Unit, clearInput: () -> Unit, + onCursorChange: (TextRange) -> Unit, + cutCallback: () -> Unit, ) { UnittoScreenWithTopBar( title = { Text(stringResource(R.string.unit_converter)) }, @@ -92,7 +96,9 @@ private fun ConverterScreen( .centerAlignedTopAppBarColors(containerColor = Color.Transparent), content = { padding -> PortraitLandscape( - modifier = Modifier.padding(padding).fillMaxSize(), + modifier = Modifier + .padding(padding) + .fillMaxSize(), content1 = { TopScreenPart( modifier = it, @@ -101,13 +107,14 @@ private fun ConverterScreen( outputValue = uiState.resultValue, unitFrom = uiState.unitFrom, unitTo = uiState.unitTo, - networkLoading = uiState.showLoading, - networkError = uiState.showError, navigateToLeftScreen = navigateToLeftScreen, navigateToRightScreen = navigateToRightScreen, swapUnits = swapMeasurements, converterMode = uiState.mode, - formatTime = uiState.formatTime + onCursorChange = onCursorChange, + cutCallback = cutCallback, + pasteCallback = processInput, + formatterSymbols = uiState.formatterSymbols, ) }, content2 = { @@ -117,7 +124,8 @@ private fun ConverterScreen( deleteDigit = deleteDigit, clearInput = clearInput, converterMode = uiState.mode, - allowVibration = uiState.allowVibration + allowVibration = uiState.allowVibration, + fractional = uiState.formatterSymbols.fractional, ) } ) @@ -125,14 +133,6 @@ private fun ConverterScreen( ) } -class PreviewUIState: PreviewParameterProvider { - override val values: Sequence - get() = listOf( - ConverterUIState(inputValue = "1234", calculatedValue = null, resultValue = "5678", showLoading = false), - ConverterUIState(inputValue = "1234", calculatedValue = "234", resultValue = "5678", showLoading = false), - ).asSequence() -} - @Preview(widthDp = 432, heightDp = 1008, device = "spec:parent=pixel_5,orientation=portrait") @Preview(widthDp = 432, heightDp = 864, device = "spec:parent=pixel_5,orientation=portrait") @Preview(widthDp = 597, heightDp = 1393, device = "spec:parent=pixel_5,orientation=portrait") @@ -140,11 +140,9 @@ class PreviewUIState: PreviewParameterProvider { @Preview(heightDp = 432, widthDp = 864, device = "spec:parent=pixel_5,orientation=landscape") @Preview(heightDp = 597, widthDp = 1393, device = "spec:parent=pixel_5,orientation=landscape") @Composable -private fun PreviewConverterScreen( - @PreviewParameter(PreviewUIState::class) uiState: ConverterUIState -) { +private fun PreviewConverterScreen() { ConverterScreen( - uiState = ConverterUIState(inputValue = "1234", calculatedValue = null, resultValue = "5678", showLoading = false), + uiState = ConverterUIState(inputValue = TextFieldValue("1234"), calculatedValue = null, resultValue = ConversionResult.Default("5678"), showLoading = false), navigateToLeftScreen = {}, navigateToRightScreen = {_, _, _ -> }, navigateToSettings = {}, @@ -153,5 +151,7 @@ private fun PreviewConverterScreen( processInput = {}, deleteDigit = {}, clearInput = {}, + onCursorChange = {}, + cutCallback = {} ) -} \ No newline at end of file +} diff --git a/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/ConverterUIState.kt b/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/ConverterUIState.kt index 6ba34cd6..f225cd85 100644 --- a/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/ConverterUIState.kt +++ b/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/ConverterUIState.kt @@ -18,7 +18,9 @@ package com.sadellie.unitto.feature.converter +import androidx.compose.ui.text.input.TextFieldValue import com.sadellie.unitto.core.base.Token +import com.sadellie.unitto.core.ui.common.textfield.FormatterSymbols import com.sadellie.unitto.data.model.AbstractUnit /** @@ -33,23 +35,30 @@ import com.sadellie.unitto.data.model.AbstractUnit * @property unitFrom Unit on the left. * @property unitTo Unit on the right. * @property mode - * @property formatTime If true will format output when converting time. * @property allowVibration When true will vibrate on button clicks. */ data class ConverterUIState( - val inputValue: String = Token._0, + val inputValue: TextFieldValue = TextFieldValue(), val calculatedValue: String? = null, - val resultValue: String = Token._0, + val resultValue: ConversionResult = ConversionResult.Default(Token.Digit._0), val showLoading: Boolean = true, val showError: Boolean = false, val unitFrom: AbstractUnit? = null, val unitTo: AbstractUnit? = null, val mode: ConverterMode = ConverterMode.DEFAULT, - val formatTime: Boolean = true, - val allowVibration: Boolean = false + val allowVibration: Boolean = false, + val formatterSymbols: FormatterSymbols = FormatterSymbols.Spaces, ) enum class ConverterMode { DEFAULT, BASE, } + +sealed class ConversionResult { + data class Default(val result: String) : ConversionResult() + data class Time(val result: String) : ConversionResult() + data class NumberBase(val result: String) : ConversionResult() + object Loading : ConversionResult() + object Error : ConversionResult() +} diff --git a/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/ConverterViewModel.kt b/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/ConverterViewModel.kt index cd5b64b5..746e0596 100644 --- a/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/ConverterViewModel.kt +++ b/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/ConverterViewModel.kt @@ -18,11 +18,14 @@ package com.sadellie.unitto.feature.converter +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.github.keelar.exprk.ExpressionException -import com.github.keelar.exprk.Expressions -import com.sadellie.unitto.core.base.Token +import com.sadellie.unitto.core.ui.common.textfield.AllFormatterSymbols +import com.sadellie.unitto.core.ui.common.textfield.addTokens +import com.sadellie.unitto.core.ui.common.textfield.deleteTokens +import com.sadellie.unitto.data.common.isExpression import com.sadellie.unitto.data.common.setMinimumRequiredScale import com.sadellie.unitto.data.common.toStringWith import com.sadellie.unitto.data.common.trimZeros @@ -39,8 +42,9 @@ import com.sadellie.unitto.data.units.remote.CurrencyUnitResponse import com.sadellie.unitto.data.userprefs.UserPreferences import com.sadellie.unitto.data.userprefs.UserPreferencesRepository import dagger.hilt.android.lifecycle.HiltViewModel +import io.github.sadellie.evaluatto.Expression +import io.github.sadellie.evaluatto.ExpressionException import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -50,11 +54,8 @@ import kotlinx.coroutines.flow.getAndUpdate import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update -import kotlinx.coroutines.isActive import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import java.math.BigDecimal -import java.math.RoundingMode import javax.inject.Inject @HiltViewModel @@ -80,25 +81,17 @@ class ConverterViewModel @Inject constructor( */ private val _unitTo: MutableStateFlow = MutableStateFlow(null) - /** - * Current input. Used when converting units. - */ - private val _input: MutableStateFlow = MutableStateFlow(Token._0) + private val _input: MutableStateFlow = MutableStateFlow(TextFieldValue()) /** * Calculation result. Null when [_input] is not an expression. */ private val _calculated: MutableStateFlow = MutableStateFlow(null) - /** - * List of latest symbols that were entered. - */ - private val _latestInputStack: MutableList = mutableListOf(_input.value) - /** * Conversion result. */ - private val _result: MutableStateFlow = MutableStateFlow(Token._0) + private val _result: MutableStateFlow = MutableStateFlow(ConversionResult.Loading) /** * True when loading something from network. @@ -113,190 +106,38 @@ class ConverterViewModel @Inject constructor( /** * Current state of UI. */ - val uiStateFlow: StateFlow = combine( + val uiState: StateFlow = combine( _input, _unitFrom, _unitTo, _calculated, _result, - _showLoading, + _userPrefs, _showError, - _userPrefs - ) { inputValue, unitFromValue, unitToValue, calculatedValue, resultValue, showLoadingValue, showErrorValue, prefs -> + _showLoading + ) { inputValue, unitFromValue, unitToValue, calculatedValue, resultValue, prefs, showError, showLoading -> return@combine ConverterUIState( inputValue = inputValue, calculatedValue = calculatedValue, - resultValue = resultValue, - showLoading = showLoadingValue, - showError = showErrorValue, + resultValue = when { + showError -> ConversionResult.Error + showLoading -> ConversionResult.Loading + else -> resultValue + }, unitFrom = unitFromValue, unitTo = unitToValue, - /** - * If there will be more modes, this should be a separate value which we update when - * changing units. - */ mode = if (_unitFrom.value is NumberBaseUnit) ConverterMode.BASE else ConverterMode.DEFAULT, - formatTime = prefs.unitConverterFormatTime, - allowVibration = prefs.enableVibrations - ) - } - .stateIn( - viewModelScope, - SharingStarted.WhileSubscribed(5000), - ConverterUIState() + allowVibration = prefs.enableVibrations, + formatterSymbols = AllFormatterSymbols.getById(prefs.separator) ) + }.stateIn( + viewModelScope, SharingStarted.WhileSubscribed(5000), ConverterUIState() + ) - /** - * Process input with rules. Makes sure that user input is corrected when needed. - * - * @param symbolToAdd Use 'ugly' version of symbols. - */ - fun processInput(symbolToAdd: String) { - val lastTwoSymbols = _latestInputStack.takeLast(2) - val lastSymbol: String = lastTwoSymbols.getOrNull(1) ?: lastTwoSymbols[0] - val lastSecondSymbol: String? = lastTwoSymbols.getOrNull(0) - - when (symbolToAdd) { - Token.plus, Token.divide, Token.multiply, Token.exponent -> { - when { - // Don't need expressions that start with zero - (_input.value == Token._0) -> {} - (_input.value == Token.minus) -> {} - (lastSymbol == Token.leftBracket) -> {} - (lastSymbol == Token.sqrt) -> {} - /** - * For situations like "50+-", when user clicks "/" we delete "-" so it becomes - * "50+". We don't add "/' here. User will click "/" second time and the input - * will be "50/". - */ - (lastSecondSymbol in Token.operators) and (lastSymbol == Token.minus) -> { - deleteDigit() - } - // Don't allow multiple operators near each other - (lastSymbol in Token.operators) -> { - deleteDigit() - setInputSymbols(symbolToAdd) - } - else -> { - setInputSymbols(symbolToAdd) - } - } - } - Token._0 -> { - when { - // Don't add zero if the input is already a zero - (_input.value == Token._0) -> {} - (lastSymbol == Token.rightBracket) -> { - processInput(Token.multiply) - setInputSymbols(symbolToAdd) - } - // Prevents things like "-00" and "4+000" - ((lastSecondSymbol in Token.operators + Token.leftBracket) and (lastSymbol == Token._0)) -> {} - else -> { - setInputSymbols(symbolToAdd) - } - } - } - Token._1, Token._2, Token._3, Token._4, Token._5, - Token._6, Token._7, Token._8, Token._9 -> { - // Replace single zero (default input) if it's here - when { - (_input.value == Token._0) -> { - setInputSymbols(symbolToAdd, false) - } - (lastSymbol == Token.rightBracket) -> { - processInput(Token.multiply) - setInputSymbols(symbolToAdd) - } - else -> { - setInputSymbols(symbolToAdd) - } - } - } - Token.minus -> { - when { - // Replace single zero with minus (to support negative numbers) - (_input.value == Token._0) -> { - setInputSymbols(symbolToAdd, false) - } - // Don't allow multiple minuses near each other - (lastSymbol.compareTo(Token.minus) == 0) -> {} - // Don't allow plus and minus be near each other - (lastSymbol == Token.plus) -> { - deleteDigit() - setInputSymbols(symbolToAdd) - } - else -> { - setInputSymbols(symbolToAdd) - } - } - } - Token.dot -> { - if (!_input.value - .takeLastWhile { it.toString() !in Token.operators.minus(Token.dot) } - .contains(Token.dot) - ) { - setInputSymbols(symbolToAdd) - } - } - Token.leftBracket -> { - when { - // Replace single zero with minus (to support negative numbers) - (_input.value == Token._0) -> { - setInputSymbols(symbolToAdd, false) - } - (lastSymbol == Token.rightBracket) || (lastSymbol in Token.digits) || (lastSymbol == Token.dot) -> { - processInput(Token.multiply) - setInputSymbols(symbolToAdd) - } - else -> { - setInputSymbols(symbolToAdd) - } - } - } - Token.rightBracket -> { - when { - // Replace single zero with minus (to support negative numbers) - (_input.value == Token._0) -> {} - (lastSymbol == Token.leftBracket) -> {} - ( - _latestInputStack.filter { it == Token.leftBracket }.size == - _latestInputStack.filter { it == Token.rightBracket }.size - ) -> { - } - else -> { - setInputSymbols(symbolToAdd) - } - } - } - Token.sqrt -> { - when { - // Replace single zero with minus (to support negative numbers) - (_input.value == Token._0) -> { - setInputSymbols(symbolToAdd, false) - } - (lastSymbol == Token.rightBracket) || (lastSymbol in Token.digits) || (lastSymbol == Token.dot) -> { - processInput(Token.multiply) - setInputSymbols(symbolToAdd) - } - else -> { - setInputSymbols(symbolToAdd) - } - } - } - else -> { - when { - // Replace single zero with minus (to support negative numbers) - (_input.value == Token._0) -> { - setInputSymbols(symbolToAdd, false) - } - else -> { - setInputSymbols(symbolToAdd) - } - } - } - } - } + fun addTokens(tokens: String) = _input.update { it.addTokens(tokens) } + fun deleteTokens() = _input.update { it.deleteTokens() } + fun clearInput() = _input.update { TextFieldValue() } + fun onCursorChange(selection: TextRange) = _input.update { it.copy(selection = selection) } /** * Update [_unitFrom] and set [_unitTo] from pair. Also updates stats for this [unit]. @@ -346,133 +187,76 @@ class ConverterViewModel @Inject constructor( updateCurrenciesRatesIfNeeded() } - /** - * Delete last symbol from [_input]. - */ - fun deleteDigit() { - // Default input, don't delete - if (_input.value == Token._0) return - - val lastSymbol = _latestInputStack.removeLast() - - // If this value are same, it means that after deleting there will be no symbols left, set to default - if (lastSymbol == _input.value) { - setInputSymbols(Token._0, false) - } else { - _input.update { it.removeSuffix(lastSymbol) } + private fun convertInput() { + when (_unitFrom.value?.group) { + UnitGroup.NUMBER_BASE -> convertAsNumberBase() + else -> convertAsExpression() } } - /** - * Clear [_input]. - */ - fun clearInput() { - setInputSymbols(Token._0, false) - } - - private suspend fun convertInput() { - withContext(Dispatchers.Default) { - while (isActive) { - when (_unitFrom.value?.group) { - UnitGroup.NUMBER_BASE -> convertAsNumberBase() - else -> convertAsExpression() - } - cancel() - } - } - } - - private fun convertAsNumberBase() { + private fun convertAsNumberBase() = viewModelScope.launch(Dispatchers.Default) { // Units are still loading, don't convert anything yet - val unitFrom = _unitFrom.value ?: return - val unitTo = _unitTo.value ?: return + val unitFrom = _unitFrom.value ?: return@launch + val unitTo = _unitTo.value ?: return@launch val conversionResult = try { (unitFrom as NumberBaseUnit).convertToBase( - input = _input.value, + input = _input.value.text.ifEmpty { "0" }, toBase = (unitTo as NumberBaseUnit).base ) } catch (e: Exception) { when (e) { - is ClassCastException -> return + is ClassCastException -> return@launch is NumberFormatException, is IllegalArgumentException -> "" else -> throw e } } - _result.update { conversionResult } + _result.update { ConversionResult.NumberBase(conversionResult) } } - private fun convertAsExpression() { - // Units are still loading, don't convert anything yet - val unitFrom = _unitFrom.value ?: return - val unitTo = _unitTo.value ?: return + private fun convertAsExpression() = viewModelScope.launch(Dispatchers.Default) { + val unitFrom = _unitFrom.value ?: return@launch + val unitTo = _unitTo.value ?: return@launch + val input = _input.value.text.ifEmpty { "0" } - // First we clean the input from garbage at the end - var cleanInput = _input.value.dropLastWhile { !it.isDigit() } - - // Now we close open brackets that user didn't close - // AUTOCLOSE ALL BRACKETS - val leftBrackets = _input.value.count { it.toString() == Token.leftBracket } - val rightBrackets = _input.value.count { it.toString() == Token.rightBracket } - val neededBrackets = leftBrackets - rightBrackets - if (neededBrackets > 0) cleanInput += Token.rightBracket.repeat(neededBrackets) - - // Now we evaluate expression in input - val evaluationResult: BigDecimal = try { - Expressions().eval(cleanInput) - .setScale(_userPrefs.value.digitsPrecision, RoundingMode.HALF_EVEN) - .trimZeros() - } catch (e: Exception) { - when (e) { - is ExpressionException, - is ArrayIndexOutOfBoundsException, - is IndexOutOfBoundsException, - is NumberFormatException, - is ArithmeticException -> { - // Invalid expression, can't do anything further - return - } - else -> throw e - } - } - - // Evaluated. Hide calculated result if no expression entered. - // 123.456 will be true - // -123.456 will be true - // -123.456-123 will be false (first minus gets removed, ending with 123.456) - if (_input.value.removePrefix(Token.minus).all { it.toString() !in Token.operators }) { - // No operators + if (input.isEmpty()) { _calculated.update { null } - } else { - _calculated.update { - evaluationResult - .setMinimumRequiredScale(_userPrefs.value.digitsPrecision) - .trimZeros() - .toStringWith(_userPrefs.value.outputFormat) - } + _result.update { ConversionResult.Default("") } + return@launch } - // Now we just convert. - // We can use evaluation result here, input is valid. - val conversionResult: BigDecimal = unitFrom.convert( - unitTo, - evaluationResult, - _userPrefs.value.digitsPrecision - ) - - // Converted - _result.update { conversionResult.toStringWith(_userPrefs.value.outputFormat) } - } - - private fun setInputSymbols(symbol: String, add: Boolean = true) { - if (add) { - _input.update { it + symbol } - } else { - // We don't need previous input, clear entirely - _latestInputStack.clear() - _input.update { symbol } + val evaluationResult = try { + Expression(input) + .calculate() + .also { + if (it > BigDecimal.valueOf(Double.MAX_VALUE)) throw ExpressionException.TooBig() + } + .setMinimumRequiredScale(_userPrefs.value.digitsPrecision) + .trimZeros() + } catch (e: ExpressionException.DivideByZero) { + _calculated.update { null } + return@launch + } catch (e: Exception) { + return@launch + } + + _calculated.update { + if (input.isExpression()) evaluationResult.toStringWith(_userPrefs.value.outputFormat) + else null + } + + val conversionResult = unitFrom.convert( + unitTo = unitTo, + value = evaluationResult, + scale = _userPrefs.value.digitsPrecision + ).toStringWith(_userPrefs.value.outputFormat) + + _result.update { + if ((unitFrom.group == UnitGroup.TIME) and (_userPrefs.value.unitConverterFormatTime)) + ConversionResult.Time(conversionResult) + else + ConversionResult.Default(conversionResult) } - _latestInputStack.add(symbol) } private fun incrementCounter(unit: AbstractUnit) { @@ -506,9 +290,10 @@ class ConverterViewModel @Inject constructor( viewModelScope.launch(Dispatchers.IO) { _showError.update { false } _showLoading.update { false } + // Units are still loading, don't convert anything yet - val unitFrom = _unitFrom.value ?: return@launch if (_unitFrom.value?.group != UnitGroup.CURRENCY) return@launch + val unitFrom = _unitFrom.value ?: return@launch // Starting to load stuff _showLoading.update { true } @@ -516,6 +301,7 @@ class ConverterViewModel @Inject constructor( val pairs: CurrencyUnitResponse = CurrencyApi.retrofitService.getCurrencyPairs(unitFrom.unitId) allUnitsRepository.updateBasicUnitsForCurrencies(pairs.currency) + convertAsExpression() } catch (e: Exception) { // Dangerous and stupid, but who cares _showError.update { true } @@ -539,7 +325,7 @@ class ConverterViewModel @Inject constructor( } private fun startObserving() { - viewModelScope.launch(Dispatchers.Default) { + viewModelScope.launch { merge(_input, _unitFrom, _unitTo, _showLoading, _userPrefs).collectLatest { convertInput() } diff --git a/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/components/Keyboard.kt b/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/components/Keyboard.kt index e999fe20..9d54af01 100644 --- a/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/components/Keyboard.kt +++ b/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/components/Keyboard.kt @@ -27,7 +27,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import com.sadellie.unitto.core.base.Token -import com.sadellie.unitto.core.ui.Formatter import com.sadellie.unitto.core.ui.common.ColumnWithConstraints import com.sadellie.unitto.core.ui.common.KeyboardButtonFilled import com.sadellie.unitto.core.ui.common.KeyboardButtonLight @@ -77,11 +76,12 @@ internal fun Keyboard( deleteDigit: () -> Unit = {}, clearInput: () -> Unit = {}, converterMode: ConverterMode, - allowVibration: Boolean + allowVibration: Boolean, + fractional: String, ) { Crossfade(converterMode, modifier = modifier) { when (it) { - ConverterMode.DEFAULT -> DefaultKeyboard(addDigit, clearInput, deleteDigit, allowVibration) + ConverterMode.DEFAULT -> DefaultKeyboard(addDigit, clearInput, deleteDigit, allowVibration, fractional) ConverterMode.BASE -> BaseKeyboard(addDigit, clearInput, deleteDigit, allowVibration) } } @@ -92,9 +92,10 @@ private fun DefaultKeyboard( addDigit: (String) -> Unit, clearInput: () -> Unit, deleteDigit: () -> Unit, - allowVibration: Boolean + allowVibration: Boolean, + fractional: String, ) { - val fractionalIcon = remember { if (Formatter.fractional == Token.dot) UnittoIcons.Dot else UnittoIcons.Comma } + val fractionalIcon = remember { if (fractional == Token.Digit.dot) UnittoIcons.Dot else UnittoIcons.Comma } ColumnWithConstraints { // Button modifier val bModifier = Modifier @@ -104,34 +105,34 @@ private fun DefaultKeyboard( val cModifier = Modifier.weight(1f).padding(vertical = it.maxHeight * 0.01f) val horizontalArrangement = Arrangement.spacedBy(it.maxWidth * 0.03f) Row(cModifier, horizontalArrangement) { - KeyboardButtonFilled(bModifier, UnittoIcons.LeftBracket, allowVibration) { addDigit(Token.leftBracket) } - KeyboardButtonFilled(bModifier, UnittoIcons.RightBracket, allowVibration) { addDigit(Token.rightBracket) } - KeyboardButtonFilled(bModifier, UnittoIcons.Exponent, allowVibration) { addDigit(Token.exponent) } - KeyboardButtonFilled(bModifier, UnittoIcons.SquareRoot, allowVibration) { addDigit(Token.sqrt) } + KeyboardButtonFilled(bModifier, UnittoIcons.LeftBracket, allowVibration) { addDigit(Token.Operator.leftBracket) } + KeyboardButtonFilled(bModifier, UnittoIcons.RightBracket, allowVibration) { addDigit(Token.Operator.rightBracket) } + KeyboardButtonFilled(bModifier, UnittoIcons.Exponent, allowVibration) { addDigit(Token.Operator.power) } + KeyboardButtonFilled(bModifier, UnittoIcons.SquareRoot, allowVibration) { addDigit(Token.Operator.sqrt) } } Row(cModifier, horizontalArrangement) { - KeyboardButtonLight(bModifier, UnittoIcons.Key7, allowVibration) { addDigit(Token._7) } - KeyboardButtonLight(bModifier, UnittoIcons.Key8, allowVibration) { addDigit(Token._8) } - KeyboardButtonLight(bModifier, UnittoIcons.Key9, allowVibration) { addDigit(Token._9) } - KeyboardButtonFilled(bModifier, UnittoIcons.Divide, allowVibration) { addDigit(Token.divide) } + KeyboardButtonLight(bModifier, UnittoIcons.Key7, allowVibration) { addDigit(Token.Digit._7) } + KeyboardButtonLight(bModifier, UnittoIcons.Key8, allowVibration) { addDigit(Token.Digit._8) } + KeyboardButtonLight(bModifier, UnittoIcons.Key9, allowVibration) { addDigit(Token.Digit._9) } + KeyboardButtonFilled(bModifier, UnittoIcons.Divide, allowVibration) { addDigit(Token.Operator.divide) } } Row(cModifier, horizontalArrangement) { - KeyboardButtonLight(bModifier, UnittoIcons.Key4, allowVibration) { addDigit(Token._4) } - KeyboardButtonLight(bModifier, UnittoIcons.Key5, allowVibration) { addDigit(Token._5) } - KeyboardButtonLight(bModifier, UnittoIcons.Key6, allowVibration) { addDigit(Token._6) } - KeyboardButtonFilled(bModifier, UnittoIcons.Multiply, allowVibration) { addDigit(Token.multiply) } + KeyboardButtonLight(bModifier, UnittoIcons.Key4, allowVibration) { addDigit(Token.Digit._4) } + KeyboardButtonLight(bModifier, UnittoIcons.Key5, allowVibration) { addDigit(Token.Digit._5) } + KeyboardButtonLight(bModifier, UnittoIcons.Key6, allowVibration) { addDigit(Token.Digit._6) } + KeyboardButtonFilled(bModifier, UnittoIcons.Multiply, allowVibration) { addDigit(Token.Operator.multiply) } } Row(cModifier, horizontalArrangement) { - KeyboardButtonLight(bModifier, UnittoIcons.Key1, allowVibration) { addDigit(Token._1) } - KeyboardButtonLight(bModifier, UnittoIcons.Key2, allowVibration) { addDigit(Token._2) } - KeyboardButtonLight(bModifier, UnittoIcons.Key3, allowVibration) { addDigit(Token._3) } - KeyboardButtonFilled(bModifier, UnittoIcons.Minus, allowVibration) { addDigit(Token.minus) } + KeyboardButtonLight(bModifier, UnittoIcons.Key1, allowVibration) { addDigit(Token.Digit._1) } + KeyboardButtonLight(bModifier, UnittoIcons.Key2, allowVibration) { addDigit(Token.Digit._2) } + KeyboardButtonLight(bModifier, UnittoIcons.Key3, allowVibration) { addDigit(Token.Digit._3) } + KeyboardButtonFilled(bModifier, UnittoIcons.Minus, allowVibration) { addDigit(Token.Operator.minus) } } Row(cModifier, horizontalArrangement) { - KeyboardButtonLight(bModifier, UnittoIcons.Key0, allowVibration) { addDigit(Token._0) } - KeyboardButtonLight(bModifier, fractionalIcon, allowVibration) { addDigit(Token.dot) } + KeyboardButtonLight(bModifier, UnittoIcons.Key0, allowVibration) { addDigit(Token.Digit._0) } + KeyboardButtonLight(bModifier, fractionalIcon, allowVibration) { addDigit(Token.Digit.dot) } KeyboardButtonLight(bModifier, UnittoIcons.Backspace, allowVibration, clearInput) { deleteDigit() } - KeyboardButtonFilled(bModifier, UnittoIcons.Plus, allowVibration) { addDigit(Token.plus) } + KeyboardButtonFilled(bModifier, UnittoIcons.Plus, allowVibration) { addDigit(Token.Operator.plus) } } } } @@ -152,32 +153,32 @@ private fun BaseKeyboard( val cModifier = Modifier.weight(1f).padding(vertical = it.maxHeight * 0.01f) val horizontalArrangement = Arrangement.spacedBy(it.maxWidth * 0.03f) Row(cModifier, horizontalArrangement) { - KeyboardButtonFilled(bModifier, UnittoIcons.KeyA, allowVibration) { addDigit(Token.baseA) } - KeyboardButtonFilled(bModifier, UnittoIcons.KeyB, allowVibration) { addDigit(Token.baseB) } - KeyboardButtonFilled(bModifier, UnittoIcons.KeyC, allowVibration) { addDigit(Token.baseC) } + KeyboardButtonFilled(bModifier, UnittoIcons.KeyA, allowVibration) { addDigit(Token.Letter._A) } + KeyboardButtonFilled(bModifier, UnittoIcons.KeyB, allowVibration) { addDigit(Token.Letter._B) } + KeyboardButtonFilled(bModifier, UnittoIcons.KeyC, allowVibration) { addDigit(Token.Letter._C) } } Row(cModifier, horizontalArrangement) { - KeyboardButtonFilled(bModifier, UnittoIcons.KeyD, allowVibration) { addDigit(Token.baseD) } - KeyboardButtonFilled(bModifier, UnittoIcons.KeyE, allowVibration) { addDigit(Token.baseE) } - KeyboardButtonFilled(bModifier, UnittoIcons.KeyF, allowVibration) { addDigit(Token.baseF) } + KeyboardButtonFilled(bModifier, UnittoIcons.KeyD, allowVibration) { addDigit(Token.Letter._D) } + KeyboardButtonFilled(bModifier, UnittoIcons.KeyE, allowVibration) { addDigit(Token.Letter._E) } + KeyboardButtonFilled(bModifier, UnittoIcons.KeyF, allowVibration) { addDigit(Token.Letter._F) } } Row(cModifier, horizontalArrangement) { - KeyboardButtonLight(bModifier, UnittoIcons.Key7, allowVibration) { addDigit(Token._7) } - KeyboardButtonLight(bModifier, UnittoIcons.Key8, allowVibration) { addDigit(Token._8) } - KeyboardButtonLight(bModifier, UnittoIcons.Key9, allowVibration) { addDigit(Token._9) } + KeyboardButtonLight(bModifier, UnittoIcons.Key7, allowVibration) { addDigit(Token.Digit._7) } + KeyboardButtonLight(bModifier, UnittoIcons.Key8, allowVibration) { addDigit(Token.Digit._8) } + KeyboardButtonLight(bModifier, UnittoIcons.Key9, allowVibration) { addDigit(Token.Digit._9) } } Row(cModifier, horizontalArrangement) { - KeyboardButtonLight(bModifier, UnittoIcons.Key4, allowVibration) { addDigit(Token._4) } - KeyboardButtonLight(bModifier, UnittoIcons.Key5, allowVibration) { addDigit(Token._5) } - KeyboardButtonLight(bModifier, UnittoIcons.Key6, allowVibration) { addDigit(Token._6) } + KeyboardButtonLight(bModifier, UnittoIcons.Key4, allowVibration) { addDigit(Token.Digit._4) } + KeyboardButtonLight(bModifier, UnittoIcons.Key5, allowVibration) { addDigit(Token.Digit._5) } + KeyboardButtonLight(bModifier, UnittoIcons.Key6, allowVibration) { addDigit(Token.Digit._6) } } Row(cModifier, horizontalArrangement) { - KeyboardButtonLight(bModifier, UnittoIcons.Key1, allowVibration) { addDigit(Token._1) } - KeyboardButtonLight(bModifier, UnittoIcons.Key2, allowVibration) { addDigit(Token._2) } - KeyboardButtonLight(bModifier, UnittoIcons.Key3, allowVibration) { addDigit(Token._3) } + KeyboardButtonLight(bModifier, UnittoIcons.Key1, allowVibration) { addDigit(Token.Digit._1) } + KeyboardButtonLight(bModifier, UnittoIcons.Key2, allowVibration) { addDigit(Token.Digit._2) } + KeyboardButtonLight(bModifier, UnittoIcons.Key3, allowVibration) { addDigit(Token.Digit._3) } } Row(cModifier, horizontalArrangement) { - KeyboardButtonLight(bModifier, UnittoIcons.Key0, allowVibration) { addDigit(Token._0) } + KeyboardButtonLight(bModifier, UnittoIcons.Key0, allowVibration) { addDigit(Token.Digit._0) } KeyboardButtonLight( Modifier.fillMaxSize().weight(2f).padding(it.maxWidth * 0.015f, it.maxHeight * 0.008f), UnittoIcons.Backspace, allowVibration, clearInput) { deleteDigit() } } diff --git a/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/components/MyTextField.kt b/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/components/MyTextField.kt deleted file mode 100644 index e344d063..00000000 --- a/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/components/MyTextField.kt +++ /dev/null @@ -1,174 +0,0 @@ -/* - * Unitto is a unit converter for Android - * Copyright (c) 2022-2022 Elshan Agaev - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package com.sadellie.unitto.feature.converter.components - -import android.widget.Toast -import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.SizeTransform -import androidx.compose.animation.expandHorizontally -import androidx.compose.animation.expandVertically -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.shrinkVertically -import androidx.compose.animation.with -import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.wrapContentHeight -import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.material.ripple.rememberRipple -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalClipboardManager -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import com.sadellie.unitto.core.ui.R -import com.sadellie.unitto.core.ui.common.textfield.InputTextField -import com.sadellie.unitto.core.ui.theme.NumbersTextStyleDisplayLarge - -/** - * Component for input and output - * - * @param modifier Modifier that is applied to [LazyRow]. - * @param primaryText Primary text to show (input/output). - * @param secondaryText Secondary text to show (input, calculated result). - * @param helperText Helper text below current text (short unit name). - * @param textToCopy Text that will be copied to clipboard when long-clicking. - */ -@Composable -internal fun MyTextField( - modifier: Modifier, - primaryText: @Composable () -> String, - secondaryText: String?, - helperText: String, - textToCopy: String, - onClick: () -> Unit = {}, -) { - val clipboardManager = LocalClipboardManager.current - val mc = LocalContext.current - val textToShow: String = primaryText() - val copiedText: String = - stringResource(R.string.copied, textToCopy) - - Column( - modifier = Modifier - .combinedClickable( - interactionSource = remember { MutableInteractionSource() }, - indication = rememberRipple(), - onClick = onClick, - onLongClick = { - clipboardManager.setText(AnnotatedString(secondaryText ?: textToShow)) - Toast - .makeText(mc, copiedText, Toast.LENGTH_SHORT) - .show() - } - ) - ) { - LazyRow( - modifier = modifier - .wrapContentHeight() - .weight(2f), - reverseLayout = true, - horizontalArrangement = Arrangement.End, - contentPadding = PaddingValues(horizontal = 8.dp) - ) { - item { - AnimatedContent( - targetState = textToShow, - transitionSpec = { - // Enter animation - (expandHorizontally(clip = false, expandFrom = Alignment.Start) + fadeIn() - // Exit animation - with fadeOut()) - .using(SizeTransform(clip = false)) - } - ) { - InputTextField( - modifier = Modifier.fillMaxWidth(), - value = it.take(1000), - textStyle = NumbersTextStyleDisplayLarge.copy(textAlign = TextAlign.End) - ) - } - } - } - - AnimatedVisibility( - modifier = Modifier.weight(1f), - visible = !secondaryText.isNullOrEmpty(), - enter = expandVertically(), - exit = shrinkVertically() - ) { - LazyRow( - modifier = modifier - .wrapContentHeight(), - reverseLayout = true, - horizontalArrangement = Arrangement.End, - contentPadding = PaddingValues(horizontal = 8.dp) - ) { - item { - AnimatedContent( - targetState = secondaryText, - transitionSpec = { - // Enter animation - (expandHorizontally(clip = false, expandFrom = Alignment.Start) + fadeIn() - // Exit animation - with fadeOut()) - .using(SizeTransform(clip = false)) - } - ) { - InputTextField( - modifier = Modifier.fillMaxWidth(), - value = it?.take(1000) ?: "", - textStyle = NumbersTextStyleDisplayLarge.copy( - textAlign = TextAlign.End, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) - ), - minRatio = 0.7f - ) - } - } - } - } - - AnimatedContent( - modifier = Modifier - .align(Alignment.End) - .padding(horizontal = 8.dp) - .weight(1f), - targetState = helperText - ) { - Text( - text = it, - style = MaterialTheme.typography.bodyMedium - ) - } - } -} diff --git a/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/components/TopScreen.kt b/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/components/TopScreen.kt index 39f1af65..1a971bb9 100644 --- a/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/components/TopScreen.kt +++ b/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/components/TopScreen.kt @@ -20,6 +20,7 @@ package com.sadellie.unitto.feature.converter.components import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.Crossfade import androidx.compose.animation.SizeTransform import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.animateFloatAsState @@ -50,13 +51,19 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.rotate import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextAlign -import com.sadellie.unitto.core.ui.Formatter +import com.sadellie.unitto.core.base.Token import com.sadellie.unitto.core.ui.R import com.sadellie.unitto.core.ui.common.ColumnWithConstraints -import com.sadellie.unitto.core.ui.common.textfield.InputTextField +import com.sadellie.unitto.core.ui.common.textfield.ExpressionTextField +import com.sadellie.unitto.core.ui.common.textfield.FormatterSymbols +import com.sadellie.unitto.core.ui.common.textfield.UnformattedTextField +import com.sadellie.unitto.core.ui.common.textfield.formatExpression +import com.sadellie.unitto.core.ui.common.textfield.formatTime import com.sadellie.unitto.data.model.AbstractUnit -import com.sadellie.unitto.data.model.UnitGroup +import com.sadellie.unitto.feature.converter.ConversionResult import com.sadellie.unitto.feature.converter.ConverterMode /** @@ -70,29 +77,27 @@ import com.sadellie.unitto.feature.converter.ConverterMode * @param outputValue Current output value (like big decimal). * @param unitFrom [AbstractUnit] on the left. * @param unitTo [AbstractUnit] on the right. - * @param networkLoading Are we loading data from network? Shows loading text in TextFields. - * @param networkError Did we got errors while trying to get data from network. * @param navigateToLeftScreen Function that is called when clicking left unit selection button. * @param navigateToRightScreen Function that is called when clicking right unit selection button. * @param swapUnits Method to swap units. * @param converterMode [ConverterMode.BASE] doesn't use formatting for input/output. - * @param formatTime If True will use [Formatter.formatTime]. */ @Composable internal fun TopScreenPart( modifier: Modifier, - inputValue: String, + inputValue: TextFieldValue, calculatedValue: String?, - outputValue: String, + outputValue: ConversionResult, unitFrom: AbstractUnit?, unitTo: AbstractUnit?, - networkLoading: Boolean, - networkError: Boolean, navigateToLeftScreen: (String) -> Unit, - navigateToRightScreen: (unitFrom: String, unitTo: String, input: String) -> Unit, + navigateToRightScreen: (unitFrom: String, unitTo: String, input: String?) -> Unit, swapUnits: () -> Unit, converterMode: ConverterMode, - formatTime: Boolean, + onCursorChange: (TextRange) -> Unit, + cutCallback: () -> Unit, + pasteCallback: (String) -> Unit, + formatterSymbols: FormatterSymbols, ) { var swapped by remember { mutableStateOf(false) } val swapButtonRotation: Float by animateFloatAsState( @@ -104,24 +109,48 @@ internal fun TopScreenPart( ColumnWithConstraints( modifier = modifier, ) { - InputTextField( - modifier = Modifier.weight(2f), - value = when (converterMode) { - ConverterMode.BASE -> inputValue.uppercase() - else -> Formatter.format(inputValue) - }, - minRatio = 0.7f - ) + Crossfade(modifier = Modifier.weight(2f), targetState = converterMode) { mode -> + if (mode == ConverterMode.BASE) { + UnformattedTextField( + modifier = Modifier, + value = inputValue, + onCursorChange = onCursorChange, + minRatio = 0.7f, + cutCallback = cutCallback, + pasteCallback = pasteCallback, + placeholder = Token.Digit._0 + ) + } else { + ExpressionTextField( + modifier = Modifier, + value = inputValue, + onCursorChange = onCursorChange, + formatterSymbols = formatterSymbols, + minRatio = 0.7f, + cutCallback = cutCallback, + pasteCallback = pasteCallback, + placeholder = Token.Digit._0 + ) + } + } AnimatedVisibility( visible = !calculatedValue.isNullOrEmpty(), modifier = Modifier.weight(1f), enter = expandVertically(clip = false), exit = shrinkVertically(clip = false) ) { - InputTextField( - value = calculatedValue?.let { value -> Formatter.format(value) } ?: "", - minRatio = 0.7f, - textColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) + var calculatedTextFieldValue by remember(calculatedValue) { + mutableStateOf( + TextFieldValue(calculatedValue?.formatExpression(formatterSymbols) ?: "") + ) + } + ExpressionTextField( + modifier = Modifier, + value = calculatedTextFieldValue, + onCursorChange = { calculatedTextFieldValue = calculatedTextFieldValue.copy(selection = it) }, + formatterSymbols = formatterSymbols, + textColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + minRatio = 0.7f ) } AnimatedContent( @@ -141,24 +170,74 @@ internal fun TopScreenPart( ) } - InputTextField( - modifier = Modifier - .weight(2f), - value = when { - networkLoading -> stringResource(R.string.loading_label) - networkError -> stringResource(R.string.error_label) - converterMode == ConverterMode.BASE -> outputValue.uppercase() - formatTime and (unitTo?.group == UnitGroup.TIME) -> { - Formatter.formatTime( - context = mContext, - input = calculatedValue ?: inputValue, - basicUnit = unitFrom?.basicUnit + when (outputValue) { + is ConversionResult.Default -> { + var outputTextFieldValue: TextFieldValue by remember(outputValue) { + mutableStateOf(TextFieldValue(outputValue.result)) + } + ExpressionTextField( + modifier = Modifier.weight(2f), + value = outputTextFieldValue, + onCursorChange = { outputTextFieldValue = outputTextFieldValue.copy(selection = it) }, + formatterSymbols = formatterSymbols, + readOnly = true, + minRatio = 0.7f + ) + } + + is ConversionResult.Time -> { + var outputTextFieldValue: TextFieldValue by remember(outputValue) { + mutableStateOf( + TextFieldValue( + outputValue.result + .formatTime(mContext, unitTo?.basicUnit, formatterSymbols) + ) ) } - else -> Formatter.format(outputValue) - }, - minRatio = 0.7f, - ) + UnformattedTextField( + modifier = Modifier.weight(2f), + value = outputTextFieldValue, + onCursorChange = { outputTextFieldValue = outputTextFieldValue.copy(selection = it) }, + minRatio = 0.7f, + readOnly = true + ) + } + + is ConversionResult.NumberBase -> { + var outputTextFieldValue: TextFieldValue by remember(outputValue) { + mutableStateOf(TextFieldValue(outputValue.result.uppercase())) + } + UnformattedTextField( + modifier = Modifier.weight(2f), + value = outputTextFieldValue, + onCursorChange = { outputTextFieldValue = outputTextFieldValue.copy(selection = it) }, + minRatio = 0.7f, + readOnly = true + ) + } + + is ConversionResult.Loading -> { + UnformattedTextField( + modifier = Modifier.weight(2f), + value = TextFieldValue(stringResource(R.string.loading_label)), + onCursorChange = {}, + minRatio = 0.7f, + readOnly = true + ) + } + + is ConversionResult.Error -> { + UnformattedTextField( + modifier = Modifier.weight(2f), + value = TextFieldValue(stringResource(R.string.error_label)), + onCursorChange = {}, + minRatio = 0.7f, + readOnly = true, + textColor = MaterialTheme.colorScheme.error + ) + } + } + AnimatedContent( modifier = Modifier.fillMaxWidth(), targetState = stringResource(unitTo?.shortName ?: R.string.loading_label), @@ -206,10 +285,16 @@ internal fun TopScreenPart( onClick = { if (unitTo == null) return@UnitSelectionButton if (unitFrom == null) return@UnitSelectionButton + + val input = when (outputValue) { + is ConversionResult.Error, ConversionResult.Loading -> null + else -> calculatedValue ?: inputValue.text + } + navigateToRightScreen( unitFrom.unitId, unitTo.unitId, - calculatedValue ?: inputValue + input ) }, label = unitTo?.displayName ?: R.string.loading_label, diff --git a/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/navigation/ConverterNavigation.kt b/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/navigation/ConverterNavigation.kt index aed19e68..ca10a128 100644 --- a/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/navigation/ConverterNavigation.kt +++ b/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/navigation/ConverterNavigation.kt @@ -28,7 +28,7 @@ private val converterRoute: String by lazy { TopLevelDestinations.Converter.rout fun NavGraphBuilder.converterScreen( navigateToLeftScreen: (String) -> Unit, - navigateToRightScreen: (unitFrom: String, unitTo: String, input: String) -> Unit, + navigateToRightScreen: (unitFrom: String, unitTo: String, input: String?) -> Unit, navigateToSettings: () -> Unit, navigateToMenu: () -> Unit, viewModel: ConverterViewModel diff --git a/feature/converter/src/test/java/com/sadellie/unitto/feature/converter/ConverterViewModelTest.kt b/feature/converter/src/test/java/com/sadellie/unitto/feature/converter/ConverterViewModelTest.kt deleted file mode 100644 index 013d6ae4..00000000 --- a/feature/converter/src/test/java/com/sadellie/unitto/feature/converter/ConverterViewModelTest.kt +++ /dev/null @@ -1,255 +0,0 @@ -/* - * Unitto is a unit converter for Android - * Copyright (c) 2022-2023 Elshan Agaev - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package com.sadellie.unitto.feature.converter - -import androidx.room.Room -import com.sadellie.unitto.core.base.Token -import com.sadellie.unitto.data.database.UnitsRepository -import com.sadellie.unitto.data.database.UnittoDatabase -import com.sadellie.unitto.data.units.AllUnitsRepository -import com.sadellie.unitto.data.userprefs.DataStoreModule -import com.sadellie.unitto.data.userprefs.UserPreferencesRepository -import junit.framework.TestCase.assertEquals -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.collect -import kotlinx.coroutines.launch -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.runTest -import org.junit.After -import org.junit.Before -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.RuntimeEnvironment -import org.robolectric.annotation.Config - -@OptIn(ExperimentalCoroutinesApi::class) -@Config(manifest = Config.NONE) -@RunWith(RobolectricTestRunner::class) -class ConverterViewModelTest { - - @ExperimentalCoroutinesApi - @get:Rule - val coroutineTestRule = CoroutineTestRule() - - private lateinit var viewModel: ConverterViewModel - private val allUnitsRepository = AllUnitsRepository() - private val database = Room.inMemoryDatabaseBuilder( - RuntimeEnvironment.getApplication(), - UnittoDatabase::class.java - ).build() - - @Before - fun setUp() { - viewModel = ConverterViewModel( - userPrefsRepository = UserPreferencesRepository( - DataStoreModule() - .provideUserPreferencesDataStore( - RuntimeEnvironment.getApplication() - ) - ), - unitRepository = UnitsRepository( - database.unitsDao() - ), - allUnitsRepository = allUnitsRepository - ) - } - - @After - fun tearDown() { - database.close() - } - - @Test - fun `test 0`() = runTest { - val collectJob = launch(UnconfinedTestDispatcher(testScheduler)) { - viewModel.uiStateFlow.collect() - } - - inputOutputTest("0", "0") - inputOutputTest("123000", "123000") - inputOutputTest("123.000", "123.000") - inputOutputTest("-000", "-0") - inputOutputTest("12+000", "12+0") - inputOutputTest("√000", "√0") - inputOutputTest("(000", "(0") - inputOutputTest("(1+12)000", "(1+12)*0") - inputOutputTest("(1.002+120)000", "(1.002+120)*0") - - collectJob.cancel() - } - - @Test - fun `test digits from 1 to 9`() = runTest { - val collectJob = launch(UnconfinedTestDispatcher(testScheduler)) { - viewModel.uiStateFlow.collect() - } - inputOutputTest("123456789", "123456789") - inputOutputTest("(1+1)111", "(1+1)*111") - collectJob.cancel() - } - - @Test - fun `test plus, divide, multiply and exponent operators`() = runTest { - val collectJob = launch(UnconfinedTestDispatcher(testScheduler)) { - viewModel.uiStateFlow.collect() - } - inputOutputTest("0+++", "0") - inputOutputTest("123+++", "123+") - inputOutputTest("1-***", "1*") - inputOutputTest("1/-+++", "1+") - inputOutputTest("0^^^", "0") - inputOutputTest("12^^^", "12^") - inputOutputTest("(^^^", "(") - inputOutputTest("(8+9)^^^", "(8+9)^") - collectJob.cancel() - } - - @Test - fun `test dot`() = runTest { - val collectJob = launch(UnconfinedTestDispatcher(testScheduler)) { - viewModel.uiStateFlow.collect() - } - inputOutputTest("0...", "0.") - inputOutputTest("1...", "1.") - inputOutputTest("1+...", "1+.") - inputOutputTest("√...", "√.") - inputOutputTest("√21...", "√21.") - inputOutputTest("√21+1.01-.23...", "√21+1.01-.23") - collectJob.cancel() - } - - @Test - fun `test minus`() = runTest { - val collectJob = launch(UnconfinedTestDispatcher(testScheduler)) { - viewModel.uiStateFlow.collect() - } - inputOutputTest("0---", "-") - inputOutputTest("12---", "12-") - inputOutputTest("12+---", "12-") - inputOutputTest("12/---", "12/-") - inputOutputTest("√---", "√-") - inputOutputTest("√///", "√") - inputOutputTest("12^----", "12^-") - collectJob.cancel() - } - - @Test - fun `test brackets`() = runTest { - val collectJob = launch(UnconfinedTestDispatcher(testScheduler)) { - viewModel.uiStateFlow.collect() - } - inputOutputTest("0)))", "0") - inputOutputTest("0(((", "(((") - inputOutputTest("√(10+2)(", "√(10+2)*(") - inputOutputTest("√(10+2./(", "√(10+2./(") - inputOutputTest("0()()))((", "((((") - inputOutputTest("√(10+2)^(", "√(10+2)^(") - collectJob.cancel() - } - - @Test - fun `test square root`() = runTest { - val collectJob = launch(UnconfinedTestDispatcher(testScheduler)) { - viewModel.uiStateFlow.collect() - } - inputOutputTest("0√√√", "√√√") - inputOutputTest("123√√√", "123*√√√") - collectJob.cancel() - } - - @Test - fun deleteSymbolTest() = runTest { - val collectJob = launch(UnconfinedTestDispatcher(testScheduler)) { - viewModel.uiStateFlow.collect() - } - - listOf( - Token._1, Token._2, Token._3, Token._4, Token._5, - Token._6, Token._7, Token._8, Token._9, Token._0, - Token.dot, Token.comma, Token.leftBracket, Token.rightBracket, - Token.plus, Token.minus, Token.divide, Token.multiply, - Token.exponent, Token.sqrt - ).forEach { - // We enter one symbol and delete it, should be default as a result - viewModel.processInput(it) - viewModel.deleteDigit() - assertEquals("0", viewModel.uiStateFlow.value.inputValue) - } - viewModel.clearInput() - - // This should not delete default input (0) - viewModel.deleteDigit() - - // Now we check that we can delete multiple values - viewModel.processInput(Token._3) - viewModel.processInput(Token.sqrt) - viewModel.processInput(Token._9) - viewModel.deleteDigit() - assertEquals("3*√", viewModel.uiStateFlow.value.inputValue) - - collectJob.cancel() - } - - @Test - fun clearInputTest() = runTest { - val collectJob = launch(UnconfinedTestDispatcher(testScheduler)) { - viewModel.uiStateFlow.collect() - } - - viewModel.processInput(Token._3) - viewModel.clearInput() - assertEquals(null, viewModel.uiStateFlow.value.calculatedValue) - - viewModel.processInput(Token._3) - viewModel.processInput(Token.multiply) - viewModel.clearInput() - assertEquals(null, viewModel.uiStateFlow.value.calculatedValue) - - collectJob.cancel() - } - - @Test - fun swapUnitsTest() = runTest { - val collectJob = launch(UnconfinedTestDispatcher(testScheduler)) { - viewModel.uiStateFlow.collect() - } - val initialFrom = viewModel.uiStateFlow.value.unitFrom?.unitId - val initialTo = viewModel.uiStateFlow.value.unitTo?.unitId - - viewModel.swapUnits() - assertEquals(initialTo, viewModel.uiStateFlow.value.unitFrom?.unitId) - assertEquals(initialFrom, viewModel.uiStateFlow.value.unitTo?.unitId) - - collectJob.cancel() - } - - /** - * Takes [input] sequence as a single string (e.g. "123-23") and compares it with [output]. - */ - private fun inputOutputTest(input: String, output: String) { - // Enter everything - input.forEach { - viewModel.processInput(it.toString()) - } - assertEquals(output, viewModel.uiStateFlow.value.inputValue) - viewModel.clearInput() - } -} diff --git a/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/SettingsViewModel.kt b/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/SettingsViewModel.kt index 0b6a0598..488ab511 100644 --- a/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/SettingsViewModel.kt +++ b/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/SettingsViewModel.kt @@ -21,7 +21,6 @@ package com.sadellie.unitto.feature.settings import androidx.compose.ui.graphics.Color import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.sadellie.unitto.core.ui.Formatter import com.sadellie.unitto.data.model.UnitGroup import com.sadellie.unitto.data.model.UnitsListSorting import com.sadellie.unitto.data.unitgroups.UnitGroupsRepository @@ -32,7 +31,6 @@ import io.github.sadellie.themmo.MonetMode import io.github.sadellie.themmo.ThemingMode import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import org.burnoutcrew.reorderable.ItemPosition @@ -44,7 +42,6 @@ class SettingsViewModel @Inject constructor( private val unitGroupsRepository: UnitGroupsRepository, ) : ViewModel() { val userPrefs = userPrefsRepository.userPreferencesFlow - .onEach { Formatter.setSeparator(it.separator) } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), UserPreferences() ) diff --git a/feature/unitslist/src/main/java/com/sadellie/unitto/feature/unitslist/RightSideScreen.kt b/feature/unitslist/src/main/java/com/sadellie/unitto/feature/unitslist/RightSideScreen.kt index 21f94644..083e0292 100644 --- a/feature/unitslist/src/main/java/com/sadellie/unitto/feature/unitslist/RightSideScreen.kt +++ b/feature/unitslist/src/main/java/com/sadellie/unitto/feature/unitslist/RightSideScreen.kt @@ -32,7 +32,8 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.stringResource import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.sadellie.unitto.core.ui.Formatter +import com.sadellie.unitto.core.ui.common.textfield.FormatterSymbols +import com.sadellie.unitto.core.ui.common.textfield.formatExpression import com.sadellie.unitto.data.model.AbstractUnit import com.sadellie.unitto.data.model.NumberBaseUnit import com.sadellie.unitto.data.model.UnitGroup @@ -60,7 +61,7 @@ internal fun RightSideScreen( navigateUp: () -> Unit, navigateToSettingsAction: () -> Unit, selectAction: (AbstractUnit) -> Unit, - inputValue: String, + inputValue: String?, unitFrom: AbstractUnit ) { val uiState = viewModel.mainFlow.collectAsStateWithLifecycle() @@ -69,12 +70,21 @@ internal fun RightSideScreen( val convertMethod: (AbstractUnit) -> String = try { val inputAsBigDecimal = BigDecimal(inputValue) - if (unitFrom.group == UnitGroup.NUMBER_BASE) { - { (convertForSecondaryNumberBase(inputValue, unitFrom, it)) } - } else { - { convertForSecondary(inputAsBigDecimal, unitFrom, it) } + + when { + inputValue.isNullOrEmpty() -> { { "" } } + + unitFrom.group == UnitGroup.NUMBER_BASE -> { + { (convertForSecondaryNumberBase(inputValue, unitFrom, it)) } + } + + else -> { + { + convertForSecondary(inputAsBigDecimal, unitFrom, it, uiState.value.formatterSymbols) + } + } } - } catch(e: Exception) { + } catch (e: Exception) { { "" } } @@ -131,15 +141,26 @@ internal fun RightSideScreen( } } -private fun convertForSecondary(inputValue: BigDecimal, unitFrom: AbstractUnit, unitTo: AbstractUnit): String { - return Formatter.format( - unitFrom.convert(unitTo, inputValue, 3).toPlainString() - ) + " " +private fun convertForSecondary( + inputValue: BigDecimal, + unitFrom: AbstractUnit, + unitTo: AbstractUnit, + formatterSymbols: FormatterSymbols +): String { + return unitFrom.convert(unitTo, inputValue, 3).toPlainString() + .formatExpression(formatterSymbols) + " " } -private fun convertForSecondaryNumberBase(inputValue: String, unitFrom: AbstractUnit, unitTo: AbstractUnit): String { +private fun convertForSecondaryNumberBase( + inputValue: String, + unitFrom: AbstractUnit, + unitTo: AbstractUnit +): String { return try { - (unitFrom as NumberBaseUnit).convertToBase(inputValue, (unitTo as NumberBaseUnit).base) + " " + (unitFrom as NumberBaseUnit).convertToBase( + inputValue, + (unitTo as NumberBaseUnit).base + ) + " " } catch (e: NumberFormatException) { "" } catch (e: ClassCastException) { diff --git a/feature/unitslist/src/main/java/com/sadellie/unitto/feature/unitslist/SecondScreenUIState.kt b/feature/unitslist/src/main/java/com/sadellie/unitto/feature/unitslist/SecondScreenUIState.kt index f012c8ea..57e15201 100644 --- a/feature/unitslist/src/main/java/com/sadellie/unitto/feature/unitslist/SecondScreenUIState.kt +++ b/feature/unitslist/src/main/java/com/sadellie/unitto/feature/unitslist/SecondScreenUIState.kt @@ -18,6 +18,7 @@ package com.sadellie.unitto.feature.unitslist +import com.sadellie.unitto.core.ui.common.textfield.FormatterSymbols import com.sadellie.unitto.data.model.AbstractUnit import com.sadellie.unitto.data.model.UnitGroup @@ -35,5 +36,6 @@ data class SecondScreenUIState( val unitsToShow: Map> = emptyMap(), val searchQuery: String = "", val shownUnitGroups: List = listOf(), - val chosenUnitGroup: UnitGroup? = null + val chosenUnitGroup: UnitGroup? = null, + val formatterSymbols: FormatterSymbols = FormatterSymbols.Spaces, ) diff --git a/feature/unitslist/src/main/java/com/sadellie/unitto/feature/unitslist/UnitsListViewModel.kt b/feature/unitslist/src/main/java/com/sadellie/unitto/feature/unitslist/UnitsListViewModel.kt index af2fea90..483a2351 100644 --- a/feature/unitslist/src/main/java/com/sadellie/unitto/feature/unitslist/UnitsListViewModel.kt +++ b/feature/unitslist/src/main/java/com/sadellie/unitto/feature/unitslist/UnitsListViewModel.kt @@ -21,6 +21,7 @@ package com.sadellie.unitto.feature.unitslist import android.app.Application import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.sadellie.unitto.core.ui.common.textfield.AllFormatterSymbols import com.sadellie.unitto.data.database.UnitsEntity import com.sadellie.unitto.data.database.UnitsRepository import com.sadellie.unitto.data.model.AbstractUnit @@ -74,6 +75,7 @@ class UnitsListViewModel @Inject constructor( searchQuery = searchQuery, chosenUnitGroup = chosenUnitGroup, shownUnitGroups = shownUnitGroups, + formatterSymbols = AllFormatterSymbols.getById(userPrefs.separator) ) } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), SecondScreenUIState()) diff --git a/feature/unitslist/src/main/java/com/sadellie/unitto/feature/unitslist/navigation/UnitsListNavigation.kt b/feature/unitslist/src/main/java/com/sadellie/unitto/feature/unitslist/navigation/UnitsListNavigation.kt index 8ad4d065..5692e92b 100644 --- a/feature/unitslist/src/main/java/com/sadellie/unitto/feature/unitslist/navigation/UnitsListNavigation.kt +++ b/feature/unitslist/src/main/java/com/sadellie/unitto/feature/unitslist/navigation/UnitsListNavigation.kt @@ -37,7 +37,7 @@ fun NavController.navigateToLeftSide(unitFromId: String) { navigate("$leftSideRoute/$unitFromId") } -fun NavController.navigateToRightSide(unitFromId: String, unitToId: String, input: String) { +fun NavController.navigateToRightSide(unitFromId: String, unitToId: String, input: String?) { navigate("$rightSideRoute/$unitFromId/$unitToId/$input") } @@ -72,7 +72,7 @@ fun NavGraphBuilder.rightScreen( ) { val unitFromId = it.arguments?.getString(unitFromIdArg) ?: return@composable val unitToId = it.arguments?.getString(unitToIdArg) ?: return@composable - val input = it.arguments?.getString(inputArg) ?: return@composable + val input = it.arguments?.getString(inputArg) viewModel.setSelectedChip(unitFromId, false) RightSideScreen( diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f5ec2f09..8644bca8 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -21,8 +21,6 @@ comSquareupMoshi = "1.14.0" comSquareupRetrofit2 = "2.9.0" comGithubSadellieThemmo = "ed4063f70f" orgBurnoutcrewComposereorderable = "0.9.6" -comGithubSadellieExprk = "e55cba8f41" -mxParser = "5.2.1" junit = "4.13.2" androidxTest = "1.5.0" androidxTestExt = "1.1.4" @@ -62,9 +60,7 @@ com-squareup-moshi = { group = "com.squareup.moshi", name = "moshi-kotlin", vers com-squareup-retrofit2 = { group = "com.squareup.retrofit2", name = "converter-moshi", version.ref = "comSquareupRetrofit2" } com-github-sadellie-themmo = { group = "com.github.sadellie", name = "themmo", version.ref = "comGithubSadellieThemmo" } org-burnoutcrew-composereorderable = { group = "org.burnoutcrew.composereorderable", name = "reorderable", version.ref = "orgBurnoutcrewComposereorderable" } -com-github-sadellie-exprk = { group = "com.github.sadellie", name = "ExprK", version.ref = "comGithubSadellieExprk" } android-desugarJdkLibs = { group = "com.android.tools", name = "desugar_jdk_libs", version.ref = "androidDesugarJdkLibs" } -org-mariuszgromada-math-mxparser = { group = "org.mariuszgromada.math", name = "MathParser.org-mXparser", version.ref = "mxParser" } # classpath android-gradlePlugin = { group = "com.android.tools.build", name = "gradle", version.ref = "androidGradlePlugin" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 320aa67e..7e9e80f9 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -24,12 +24,13 @@ include(":feature:converter") include(":feature:unitslist") include(":feature:calculator") include(":feature:settings") -include(":feature:epoch") +// include(":feature:epoch") include(":data:userprefs") include(":data:unitgroups") include(":data:licenses") -include(":data:epoch") +// include(":data:epoch") include(":data:calculator") include(":data:database") include(":data:model") include(":data:common") +include(":data:evaluatto") From 9156473d7ac959b205e2beb4f811baea656bbdbe Mon Sep 17 00:00:00 2001 From: Sad Ellie Date: Mon, 15 May 2023 15:50:29 +0300 Subject: [PATCH 03/51] Dependencies bump (hell) --- app/build.gradle.kts | 16 ++++++++++++---- build-logic/convention/build.gradle.kts | 6 ++++++ .../com/sadellie/unitto/ConfigureCompose.kt | 1 + .../sadellie/unitto/ConfigureKotlinAndroid.kt | 18 ++++++++++++++---- build-logic/gradle/wrapper/gradle-wrapper.jar | Bin 59203 -> 0 bytes .../gradle/wrapper/gradle-wrapper.properties | 5 ----- core/base/build.gradle.kts | 2 ++ core/ui/build.gradle.kts | 2 ++ .../com/sadellie/unitto/core/ui/Formatter.kt | 1 + .../unitto/core/ui/common/MenuButton.kt | 2 +- .../unitto/core/ui/common/NavigateUpButton.kt | 2 +- .../core/ui/common/UnittoDrawerSheet.kt | 2 +- .../unitto/core/ui/common/UnittoListItem.kt | 2 +- .../com/sadellie/unitto/core/ui/theme/Type.kt | 2 +- .../sadellie/unitto/data/model/UnitGroup.kt | 1 + .../data/units/collections/Acceleration.kt | 2 +- .../unitto/data/units/collections/Angle.kt | 2 +- .../unitto/data/units/collections/Area.kt | 2 +- .../data/units/collections/Capacitance.kt | 2 +- .../unitto/data/units/collections/Currency.kt | 2 +- .../unitto/data/units/collections/Data.kt | 2 +- .../data/units/collections/DataTransfer.kt | 2 +- .../unitto/data/units/collections/Energy.kt | 2 +- .../unitto/data/units/collections/FlowRate.kt | 2 +- .../unitto/data/units/collections/Flux.kt | 2 +- .../unitto/data/units/collections/Force.kt | 2 +- .../unitto/data/units/collections/Length.kt | 2 +- .../data/units/collections/Luminance.kt | 2 +- .../unitto/data/units/collections/Mass.kt | 2 +- .../data/units/collections/NumberBase.kt | 2 +- .../unitto/data/units/collections/Power.kt | 2 +- .../unitto/data/units/collections/Prefix.kt | 2 +- .../unitto/data/units/collections/Pressure.kt | 2 +- .../unitto/data/units/collections/Speed.kt | 2 +- .../data/units/collections/Temperature.kt | 6 +++--- .../unitto/data/units/collections/Time.kt | 2 +- .../unitto/data/units/collections/Torque.kt | 2 +- .../unitto/data/units/collections/Volume.kt | 2 +- .../feature/calculator/CalculatorScreen.kt | 3 ++- .../calculator/components/HistoryList.kt | 4 ++-- .../feature/converter/ConverterScreen.kt | 2 +- .../converter/components/MyTextField.kt | 12 ++++++------ .../feature/converter/components/TopScreen.kt | 12 ++++++------ .../components/UnitSelectionButton.kt | 14 ++++++++------ .../unitto/feature/epoch/EpochScreen.kt | 1 + .../unitto/feature/settings/AboutScreen.kt | 2 +- .../unitto/feature/settings/SettingsScreen.kt | 2 +- .../unitto/feature/settings/ThemesScreen.kt | 2 +- .../settings/ThirdPartyLicensesScreen.kt | 2 +- .../feature/settings/UnitGroupsScreen.kt | 4 ++-- .../components/AlertDialogWithList.kt | 2 +- .../feature/unitslist/LeftSideScreen.kt | 1 + .../feature/unitslist/RightSideScreen.kt | 1 + .../feature/unitslist/components/ChipsRow.kt | 2 +- .../feature/unitslist/components/SearchBar.kt | 8 +++++--- .../unitslist/components/SearchPlaceholder.kt | 2 +- .../unitslist/components/UnitListItem.kt | 8 +++++--- gradle/libs.versions.toml | 12 ++++++------ gradle/wrapper/gradle-wrapper.properties | 2 +- 59 files changed, 123 insertions(+), 87 deletions(-) delete mode 100644 build-logic/gradle/wrapper/gradle-wrapper.jar delete mode 100644 build-logic/gradle/wrapper/gradle-wrapper.properties diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 088727d6..fd17a4bc 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -16,6 +16,8 @@ * along with this program. If not, see . */ +@file:Suppress("UnstableApiUsage") + plugins { // Basic stuff id("com.android.application") @@ -74,19 +76,19 @@ android { } compileOptions { - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 isCoreLibraryDesugaringEnabled = true } - packagingOptions { + packaging { resources { excludes.add("/META-INF/{AL2.0,LGPL2.1}") } } kotlinOptions { - jvmTarget = "1.8" + jvmTarget = JavaVersion.VERSION_11.toString() freeCompilerArgs = freeCompilerArgs + listOf( "-opt-in=androidx.lifecycle.compose.ExperimentalLifecycleComposeApi" ) @@ -97,6 +99,12 @@ android { } } +tasks.withType().configureEach { + kotlinOptions { + jvmTarget = JavaVersion.VERSION_11.toString() + } +} + dependencies { implementation(libs.androidx.core) coreLibraryDesugaring(libs.android.desugarJdkLibs) diff --git a/build-logic/convention/build.gradle.kts b/build-logic/convention/build.gradle.kts index 05926ed7..b3014624 100644 --- a/build-logic/convention/build.gradle.kts +++ b/build-logic/convention/build.gradle.kts @@ -27,6 +27,12 @@ java { targetCompatibility = JavaVersion.VERSION_11 } +tasks.withType().configureEach { + kotlinOptions { + jvmTarget = JavaVersion.VERSION_11.toString() + } +} + dependencies { compileOnly(libs.android.gradlePlugin) compileOnly(libs.kotlin.gradlePlugin) diff --git a/build-logic/convention/src/main/java/com/sadellie/unitto/ConfigureCompose.kt b/build-logic/convention/src/main/java/com/sadellie/unitto/ConfigureCompose.kt index 74dfe304..a97e347d 100644 --- a/build-logic/convention/src/main/java/com/sadellie/unitto/ConfigureCompose.kt +++ b/build-logic/convention/src/main/java/com/sadellie/unitto/ConfigureCompose.kt @@ -23,6 +23,7 @@ import org.gradle.api.Project import org.gradle.api.artifacts.VersionCatalogsExtension import org.gradle.kotlin.dsl.getByType +@Suppress("UnstableApiUsage") internal fun Project.configureCompose( commonExtension: CommonExtension<*, *, *, *>, ) { diff --git a/build-logic/convention/src/main/java/com/sadellie/unitto/ConfigureKotlinAndroid.kt b/build-logic/convention/src/main/java/com/sadellie/unitto/ConfigureKotlinAndroid.kt index 1bf9d2cc..0a63a567 100644 --- a/build-logic/convention/src/main/java/com/sadellie/unitto/ConfigureKotlinAndroid.kt +++ b/build-logic/convention/src/main/java/com/sadellie/unitto/ConfigureKotlinAndroid.kt @@ -25,8 +25,11 @@ import org.gradle.api.artifacts.VersionCatalogsExtension import org.gradle.api.plugins.ExtensionAware import org.gradle.kotlin.dsl.dependencies import org.gradle.kotlin.dsl.getByType +import org.gradle.kotlin.dsl.withType import org.jetbrains.kotlin.gradle.dsl.KotlinJvmOptions +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile +@Suppress("UnstableApiUsage") internal fun Project.configureKotlinAndroid( commonExtension: CommonExtension<*, *, *, *>, ) { @@ -45,8 +48,8 @@ internal fun Project.configureKotlinAndroid( } compileOptions { - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 isCoreLibraryDesugaringEnabled = true } @@ -59,7 +62,7 @@ internal fun Project.configureKotlinAndroid( resValues = false } - packagingOptions { + packaging { resources { excludes.add("/META-INF/{AL2.0,LGPL2.1}") } @@ -73,7 +76,14 @@ internal fun Project.configureKotlinAndroid( "-opt-in=androidx.compose.ui.unit.ExperimentalUnitApi", "-opt-in=androidx.lifecycle.compose.ExperimentalLifecycleComposeApi" ) - jvmTarget = JavaVersion.VERSION_1_8.toString() + jvmTarget = JavaVersion.VERSION_11.toString() + } + } + + tasks.withType().configureEach { + kotlinOptions { + // Set JVM target to 11 + jvmTarget = JavaVersion.VERSION_11.toString() } } diff --git a/build-logic/gradle/wrapper/gradle-wrapper.jar b/build-logic/gradle/wrapper/gradle-wrapper.jar deleted file mode 100644 index e708b1c023ec8b20f512888fe07c5bd3ff77bb8f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 59203 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

K7UpUK$>l@ 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 E2O(~oYXATM diff --git a/build-logic/gradle/wrapper/gradle-wrapper.properties b/build-logic/gradle/wrapper/gradle-wrapper.properties deleted file mode 100644 index 5ff2e605..00000000 --- a/build-logic/gradle/wrapper/gradle-wrapper.properties +++ /dev/null @@ -1,5 +0,0 @@ -distributionBase=GRADLE_USER_HOME -distributionUrl=https\://services.gradle.org/distributions/gradle-7.5.1-bin.zip -distributionPath=wrapper/dists -zipStorePath=wrapper/dists -zipStoreBase=GRADLE_USER_HOME diff --git a/core/base/build.gradle.kts b/core/base/build.gradle.kts index 1ee78dca..e3c29da2 100644 --- a/core/base/build.gradle.kts +++ b/core/base/build.gradle.kts @@ -16,6 +16,8 @@ * along with this program. If not, see . */ +@file:Suppress("UnstableApiUsage") + plugins { id("unitto.library") } diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts index e7cbbc37..681bdca9 100644 --- a/core/ui/build.gradle.kts +++ b/core/ui/build.gradle.kts @@ -16,6 +16,8 @@ * along with this program. If not, see . */ +@file:Suppress("UnstableApiUsage") + plugins { id("unitto.library") id("unitto.library.compose") 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 4311461b..4fd166fb 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 @@ -19,6 +19,7 @@ package com.sadellie.unitto.core.ui import android.content.Context +import com.sadellie.unitto.core.base.R import com.sadellie.unitto.core.base.Separator import com.sadellie.unitto.core.base.Token import java.math.BigDecimal diff --git a/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/MenuButton.kt b/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/MenuButton.kt index 2d9ce43e..7d1d1202 100644 --- a/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/MenuButton.kt +++ b/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/MenuButton.kt @@ -24,7 +24,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.runtime.Composable import androidx.compose.ui.res.stringResource -import com.sadellie.unitto.core.ui.R +import com.sadellie.unitto.core.base.R /** * Button that is used in Top bars diff --git a/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/NavigateUpButton.kt b/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/NavigateUpButton.kt index 38aea268..aa480982 100644 --- a/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/NavigateUpButton.kt +++ b/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/NavigateUpButton.kt @@ -24,7 +24,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.runtime.Composable import androidx.compose.ui.res.stringResource -import com.sadellie.unitto.core.ui.R +import com.sadellie.unitto.core.base.R /** * Button that is used in Top bars diff --git a/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/UnittoDrawerSheet.kt b/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/UnittoDrawerSheet.kt index 30892e49..99f09e4c 100644 --- a/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/UnittoDrawerSheet.kt +++ b/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/UnittoDrawerSheet.kt @@ -31,8 +31,8 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.sadellie.unitto.core.base.R import com.sadellie.unitto.core.base.TopLevelDestinations -import com.sadellie.unitto.core.ui.R import com.sadellie.unitto.core.ui.model.DrawerItems @Composable diff --git a/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/UnittoListItem.kt b/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/UnittoListItem.kt index 6dcf5ae6..25e8d1d2 100644 --- a/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/UnittoListItem.kt +++ b/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/UnittoListItem.kt @@ -46,7 +46,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.rotate import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import com.sadellie.unitto.core.ui.R +import com.sadellie.unitto.core.base.R /** * Represents one item in list on Settings screen. 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 5b90a70e..5740782f 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 @@ -25,7 +25,7 @@ import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.em import androidx.compose.ui.unit.sp -import com.sadellie.unitto.core.ui.R +import com.sadellie.unitto.core.base.R private val Montserrat = FontFamily( Font(R.font.montserrat_light, weight = FontWeight.Light), diff --git a/data/model/src/main/java/com/sadellie/unitto/data/model/UnitGroup.kt b/data/model/src/main/java/com/sadellie/unitto/data/model/UnitGroup.kt index 2bb22212..9d90d44c 100644 --- a/data/model/src/main/java/com/sadellie/unitto/data/model/UnitGroup.kt +++ b/data/model/src/main/java/com/sadellie/unitto/data/model/UnitGroup.kt @@ -19,6 +19,7 @@ package com.sadellie.unitto.data.model import androidx.annotation.StringRes +import com.sadellie.unitto.core.base.R val ALL_UNIT_GROUPS: List by lazy { UnitGroup.values().toList() diff --git a/data/units/src/main/java/com/sadellie/unitto/data/units/collections/Acceleration.kt b/data/units/src/main/java/com/sadellie/unitto/data/units/collections/Acceleration.kt index 208b265b..eb79cde7 100644 --- a/data/units/src/main/java/com/sadellie/unitto/data/units/collections/Acceleration.kt +++ b/data/units/src/main/java/com/sadellie/unitto/data/units/collections/Acceleration.kt @@ -18,11 +18,11 @@ package com.sadellie.unitto.data.units.collections +import com.sadellie.unitto.core.base.R import com.sadellie.unitto.data.model.AbstractUnit import com.sadellie.unitto.data.model.DefaultUnit import com.sadellie.unitto.data.model.UnitGroup import com.sadellie.unitto.data.units.MyUnitIDS -import com.sadellie.unitto.data.units.R import java.math.BigDecimal internal val accelerationCollection: List by lazy { diff --git a/data/units/src/main/java/com/sadellie/unitto/data/units/collections/Angle.kt b/data/units/src/main/java/com/sadellie/unitto/data/units/collections/Angle.kt index 232ae30a..269f60e8 100644 --- a/data/units/src/main/java/com/sadellie/unitto/data/units/collections/Angle.kt +++ b/data/units/src/main/java/com/sadellie/unitto/data/units/collections/Angle.kt @@ -18,11 +18,11 @@ package com.sadellie.unitto.data.units.collections +import com.sadellie.unitto.core.base.R import com.sadellie.unitto.data.model.AbstractUnit import com.sadellie.unitto.data.model.DefaultUnit import com.sadellie.unitto.data.model.UnitGroup import com.sadellie.unitto.data.units.MyUnitIDS -import com.sadellie.unitto.data.units.R import java.math.BigDecimal internal val angleCollection: List by lazy { diff --git a/data/units/src/main/java/com/sadellie/unitto/data/units/collections/Area.kt b/data/units/src/main/java/com/sadellie/unitto/data/units/collections/Area.kt index 08576248..f224ca18 100644 --- a/data/units/src/main/java/com/sadellie/unitto/data/units/collections/Area.kt +++ b/data/units/src/main/java/com/sadellie/unitto/data/units/collections/Area.kt @@ -18,11 +18,11 @@ package com.sadellie.unitto.data.units.collections +import com.sadellie.unitto.core.base.R import com.sadellie.unitto.data.model.AbstractUnit import com.sadellie.unitto.data.model.DefaultUnit import com.sadellie.unitto.data.model.UnitGroup import com.sadellie.unitto.data.units.MyUnitIDS -import com.sadellie.unitto.data.units.R import java.math.BigDecimal internal val areaCollection: List by lazy { diff --git a/data/units/src/main/java/com/sadellie/unitto/data/units/collections/Capacitance.kt b/data/units/src/main/java/com/sadellie/unitto/data/units/collections/Capacitance.kt index 9a540386..c865a7c8 100644 --- a/data/units/src/main/java/com/sadellie/unitto/data/units/collections/Capacitance.kt +++ b/data/units/src/main/java/com/sadellie/unitto/data/units/collections/Capacitance.kt @@ -18,11 +18,11 @@ package com.sadellie.unitto.data.units.collections +import com.sadellie.unitto.core.base.R import com.sadellie.unitto.data.model.AbstractUnit import com.sadellie.unitto.data.model.DefaultUnit import com.sadellie.unitto.data.model.UnitGroup import com.sadellie.unitto.data.units.MyUnitIDS -import com.sadellie.unitto.data.units.R import java.math.BigDecimal internal val electrostaticCapacitance: List by lazy { diff --git a/data/units/src/main/java/com/sadellie/unitto/data/units/collections/Currency.kt b/data/units/src/main/java/com/sadellie/unitto/data/units/collections/Currency.kt index cf755987..9b9cf6a0 100644 --- a/data/units/src/main/java/com/sadellie/unitto/data/units/collections/Currency.kt +++ b/data/units/src/main/java/com/sadellie/unitto/data/units/collections/Currency.kt @@ -18,11 +18,11 @@ package com.sadellie.unitto.data.units.collections +import com.sadellie.unitto.core.base.R import com.sadellie.unitto.data.model.AbstractUnit import com.sadellie.unitto.data.model.DefaultUnit import com.sadellie.unitto.data.model.UnitGroup import com.sadellie.unitto.data.units.MyUnitIDS -import com.sadellie.unitto.data.units.R import java.math.BigDecimal internal val currencyCollection: List by lazy { diff --git a/data/units/src/main/java/com/sadellie/unitto/data/units/collections/Data.kt b/data/units/src/main/java/com/sadellie/unitto/data/units/collections/Data.kt index 4182148c..15b01d3a 100644 --- a/data/units/src/main/java/com/sadellie/unitto/data/units/collections/Data.kt +++ b/data/units/src/main/java/com/sadellie/unitto/data/units/collections/Data.kt @@ -18,11 +18,11 @@ package com.sadellie.unitto.data.units.collections +import com.sadellie.unitto.core.base.R import com.sadellie.unitto.data.model.AbstractUnit import com.sadellie.unitto.data.model.DefaultUnit import com.sadellie.unitto.data.model.UnitGroup import com.sadellie.unitto.data.units.MyUnitIDS -import com.sadellie.unitto.data.units.R import java.math.BigDecimal internal val dataCollection: List by lazy { diff --git a/data/units/src/main/java/com/sadellie/unitto/data/units/collections/DataTransfer.kt b/data/units/src/main/java/com/sadellie/unitto/data/units/collections/DataTransfer.kt index 7dfcc19a..2cbf39c9 100644 --- a/data/units/src/main/java/com/sadellie/unitto/data/units/collections/DataTransfer.kt +++ b/data/units/src/main/java/com/sadellie/unitto/data/units/collections/DataTransfer.kt @@ -18,11 +18,11 @@ package com.sadellie.unitto.data.units.collections +import com.sadellie.unitto.core.base.R import com.sadellie.unitto.data.model.AbstractUnit import com.sadellie.unitto.data.model.DefaultUnit import com.sadellie.unitto.data.model.UnitGroup import com.sadellie.unitto.data.units.MyUnitIDS -import com.sadellie.unitto.data.units.R import java.math.BigDecimal internal val dataTransferCollection: List by lazy { diff --git a/data/units/src/main/java/com/sadellie/unitto/data/units/collections/Energy.kt b/data/units/src/main/java/com/sadellie/unitto/data/units/collections/Energy.kt index 646aed2b..939db2db 100644 --- a/data/units/src/main/java/com/sadellie/unitto/data/units/collections/Energy.kt +++ b/data/units/src/main/java/com/sadellie/unitto/data/units/collections/Energy.kt @@ -18,11 +18,11 @@ package com.sadellie.unitto.data.units.collections +import com.sadellie.unitto.core.base.R import com.sadellie.unitto.data.model.AbstractUnit import com.sadellie.unitto.data.model.DefaultUnit import com.sadellie.unitto.data.model.UnitGroup import com.sadellie.unitto.data.units.MyUnitIDS -import com.sadellie.unitto.data.units.R import java.math.BigDecimal internal val energyCollection: List by lazy { diff --git a/data/units/src/main/java/com/sadellie/unitto/data/units/collections/FlowRate.kt b/data/units/src/main/java/com/sadellie/unitto/data/units/collections/FlowRate.kt index e7e3951a..962819b2 100644 --- a/data/units/src/main/java/com/sadellie/unitto/data/units/collections/FlowRate.kt +++ b/data/units/src/main/java/com/sadellie/unitto/data/units/collections/FlowRate.kt @@ -18,11 +18,11 @@ package com.sadellie.unitto.data.units.collections +import com.sadellie.unitto.core.base.R import com.sadellie.unitto.data.model.AbstractUnit import com.sadellie.unitto.data.model.FlowRateUnit import com.sadellie.unitto.data.model.UnitGroup import com.sadellie.unitto.data.units.MyUnitIDS -import com.sadellie.unitto.data.units.R import java.math.BigDecimal val flowRateCollection: List by lazy { diff --git a/data/units/src/main/java/com/sadellie/unitto/data/units/collections/Flux.kt b/data/units/src/main/java/com/sadellie/unitto/data/units/collections/Flux.kt index 2c509193..a4a3a758 100644 --- a/data/units/src/main/java/com/sadellie/unitto/data/units/collections/Flux.kt +++ b/data/units/src/main/java/com/sadellie/unitto/data/units/collections/Flux.kt @@ -18,11 +18,11 @@ package com.sadellie.unitto.data.units.collections +import com.sadellie.unitto.core.base.R import com.sadellie.unitto.data.model.AbstractUnit import com.sadellie.unitto.data.model.DefaultUnit import com.sadellie.unitto.data.model.UnitGroup import com.sadellie.unitto.data.units.MyUnitIDS -import com.sadellie.unitto.data.units.R import java.math.BigDecimal internal val fluxCollection: List by lazy { diff --git a/data/units/src/main/java/com/sadellie/unitto/data/units/collections/Force.kt b/data/units/src/main/java/com/sadellie/unitto/data/units/collections/Force.kt index ae7f94c9..7f9d41db 100644 --- a/data/units/src/main/java/com/sadellie/unitto/data/units/collections/Force.kt +++ b/data/units/src/main/java/com/sadellie/unitto/data/units/collections/Force.kt @@ -18,11 +18,11 @@ package com.sadellie.unitto.data.units.collections +import com.sadellie.unitto.core.base.R import com.sadellie.unitto.data.model.AbstractUnit import com.sadellie.unitto.data.model.DefaultUnit import com.sadellie.unitto.data.model.UnitGroup import com.sadellie.unitto.data.units.MyUnitIDS -import com.sadellie.unitto.data.units.R import java.math.BigDecimal val forceCollection: List by lazy { diff --git a/data/units/src/main/java/com/sadellie/unitto/data/units/collections/Length.kt b/data/units/src/main/java/com/sadellie/unitto/data/units/collections/Length.kt index d57b4991..6710164f 100644 --- a/data/units/src/main/java/com/sadellie/unitto/data/units/collections/Length.kt +++ b/data/units/src/main/java/com/sadellie/unitto/data/units/collections/Length.kt @@ -18,11 +18,11 @@ package com.sadellie.unitto.data.units.collections +import com.sadellie.unitto.core.base.R import com.sadellie.unitto.data.model.AbstractUnit import com.sadellie.unitto.data.model.DefaultUnit import com.sadellie.unitto.data.model.UnitGroup import com.sadellie.unitto.data.units.MyUnitIDS -import com.sadellie.unitto.data.units.R import java.math.BigDecimal internal val lengthCollection: List by lazy { diff --git a/data/units/src/main/java/com/sadellie/unitto/data/units/collections/Luminance.kt b/data/units/src/main/java/com/sadellie/unitto/data/units/collections/Luminance.kt index d7b88328..38f9aa54 100644 --- a/data/units/src/main/java/com/sadellie/unitto/data/units/collections/Luminance.kt +++ b/data/units/src/main/java/com/sadellie/unitto/data/units/collections/Luminance.kt @@ -18,11 +18,11 @@ package com.sadellie.unitto.data.units.collections +import com.sadellie.unitto.core.base.R import com.sadellie.unitto.data.model.AbstractUnit import com.sadellie.unitto.data.model.DefaultUnit import com.sadellie.unitto.data.model.UnitGroup import com.sadellie.unitto.data.units.MyUnitIDS -import com.sadellie.unitto.data.units.R import java.math.BigDecimal val luminanceCollection: List by lazy { diff --git a/data/units/src/main/java/com/sadellie/unitto/data/units/collections/Mass.kt b/data/units/src/main/java/com/sadellie/unitto/data/units/collections/Mass.kt index 4f10d33c..731a24bb 100644 --- a/data/units/src/main/java/com/sadellie/unitto/data/units/collections/Mass.kt +++ b/data/units/src/main/java/com/sadellie/unitto/data/units/collections/Mass.kt @@ -18,11 +18,11 @@ package com.sadellie.unitto.data.units.collections +import com.sadellie.unitto.core.base.R import com.sadellie.unitto.data.model.AbstractUnit import com.sadellie.unitto.data.model.DefaultUnit import com.sadellie.unitto.data.model.UnitGroup import com.sadellie.unitto.data.units.MyUnitIDS -import com.sadellie.unitto.data.units.R import java.math.BigDecimal internal val massCollection: List by lazy { diff --git a/data/units/src/main/java/com/sadellie/unitto/data/units/collections/NumberBase.kt b/data/units/src/main/java/com/sadellie/unitto/data/units/collections/NumberBase.kt index 05050356..da6bcfbf 100644 --- a/data/units/src/main/java/com/sadellie/unitto/data/units/collections/NumberBase.kt +++ b/data/units/src/main/java/com/sadellie/unitto/data/units/collections/NumberBase.kt @@ -18,11 +18,11 @@ package com.sadellie.unitto.data.units.collections +import com.sadellie.unitto.core.base.R import com.sadellie.unitto.data.model.AbstractUnit import com.sadellie.unitto.data.model.NumberBaseUnit import com.sadellie.unitto.data.model.UnitGroup import com.sadellie.unitto.data.units.MyUnitIDS -import com.sadellie.unitto.data.units.R internal val numberBaseCollection: List by lazy { listOf( diff --git a/data/units/src/main/java/com/sadellie/unitto/data/units/collections/Power.kt b/data/units/src/main/java/com/sadellie/unitto/data/units/collections/Power.kt index a6c5ac0c..abc23823 100644 --- a/data/units/src/main/java/com/sadellie/unitto/data/units/collections/Power.kt +++ b/data/units/src/main/java/com/sadellie/unitto/data/units/collections/Power.kt @@ -18,11 +18,11 @@ package com.sadellie.unitto.data.units.collections +import com.sadellie.unitto.core.base.R import com.sadellie.unitto.data.model.AbstractUnit import com.sadellie.unitto.data.model.DefaultUnit import com.sadellie.unitto.data.model.UnitGroup import com.sadellie.unitto.data.units.MyUnitIDS -import com.sadellie.unitto.data.units.R import java.math.BigDecimal internal val powerCollection: List by lazy { diff --git a/data/units/src/main/java/com/sadellie/unitto/data/units/collections/Prefix.kt b/data/units/src/main/java/com/sadellie/unitto/data/units/collections/Prefix.kt index 04d4f044..eb7d2854 100644 --- a/data/units/src/main/java/com/sadellie/unitto/data/units/collections/Prefix.kt +++ b/data/units/src/main/java/com/sadellie/unitto/data/units/collections/Prefix.kt @@ -18,11 +18,11 @@ package com.sadellie.unitto.data.units.collections +import com.sadellie.unitto.core.base.R import com.sadellie.unitto.data.model.AbstractUnit import com.sadellie.unitto.data.model.DefaultUnit import com.sadellie.unitto.data.model.UnitGroup import com.sadellie.unitto.data.units.MyUnitIDS -import com.sadellie.unitto.data.units.R import java.math.BigDecimal val prefixCollection: List by lazy { diff --git a/data/units/src/main/java/com/sadellie/unitto/data/units/collections/Pressure.kt b/data/units/src/main/java/com/sadellie/unitto/data/units/collections/Pressure.kt index 9d1a7da3..9c3c1e89 100644 --- a/data/units/src/main/java/com/sadellie/unitto/data/units/collections/Pressure.kt +++ b/data/units/src/main/java/com/sadellie/unitto/data/units/collections/Pressure.kt @@ -18,11 +18,11 @@ package com.sadellie.unitto.data.units.collections +import com.sadellie.unitto.core.base.R import com.sadellie.unitto.data.model.AbstractUnit import com.sadellie.unitto.data.model.DefaultUnit import com.sadellie.unitto.data.model.UnitGroup import com.sadellie.unitto.data.units.MyUnitIDS -import com.sadellie.unitto.data.units.R import java.math.BigDecimal internal val pressureCollection: List by lazy { diff --git a/data/units/src/main/java/com/sadellie/unitto/data/units/collections/Speed.kt b/data/units/src/main/java/com/sadellie/unitto/data/units/collections/Speed.kt index 8cc7a5ab..5bef34cc 100644 --- a/data/units/src/main/java/com/sadellie/unitto/data/units/collections/Speed.kt +++ b/data/units/src/main/java/com/sadellie/unitto/data/units/collections/Speed.kt @@ -18,11 +18,11 @@ package com.sadellie.unitto.data.units.collections +import com.sadellie.unitto.core.base.R import com.sadellie.unitto.data.model.AbstractUnit import com.sadellie.unitto.data.model.DefaultUnit import com.sadellie.unitto.data.model.UnitGroup import com.sadellie.unitto.data.units.MyUnitIDS -import com.sadellie.unitto.data.units.R import java.math.BigDecimal internal val speedCollection: List by lazy { diff --git a/data/units/src/main/java/com/sadellie/unitto/data/units/collections/Temperature.kt b/data/units/src/main/java/com/sadellie/unitto/data/units/collections/Temperature.kt index e84d446b..faa8895b 100644 --- a/data/units/src/main/java/com/sadellie/unitto/data/units/collections/Temperature.kt +++ b/data/units/src/main/java/com/sadellie/unitto/data/units/collections/Temperature.kt @@ -19,12 +19,12 @@ package com.sadellie.unitto.data.units.collections import com.sadellie.unitto.core.base.MAX_PRECISION -import com.sadellie.unitto.data.model.AbstractUnit -import com.sadellie.unitto.data.model.UnitGroup +import com.sadellie.unitto.core.base.R import com.sadellie.unitto.data.common.setMinimumRequiredScale import com.sadellie.unitto.data.common.trimZeros +import com.sadellie.unitto.data.model.AbstractUnit +import com.sadellie.unitto.data.model.UnitGroup import com.sadellie.unitto.data.units.MyUnitIDS -import com.sadellie.unitto.data.units.R import java.math.BigDecimal import java.math.RoundingMode diff --git a/data/units/src/main/java/com/sadellie/unitto/data/units/collections/Time.kt b/data/units/src/main/java/com/sadellie/unitto/data/units/collections/Time.kt index 964a147b..29f8de12 100644 --- a/data/units/src/main/java/com/sadellie/unitto/data/units/collections/Time.kt +++ b/data/units/src/main/java/com/sadellie/unitto/data/units/collections/Time.kt @@ -18,11 +18,11 @@ package com.sadellie.unitto.data.units.collections +import com.sadellie.unitto.core.base.R import com.sadellie.unitto.data.model.AbstractUnit import com.sadellie.unitto.data.model.DefaultUnit import com.sadellie.unitto.data.model.UnitGroup import com.sadellie.unitto.data.units.MyUnitIDS -import com.sadellie.unitto.data.units.R import java.math.BigDecimal internal val timeCollection: List by lazy { diff --git a/data/units/src/main/java/com/sadellie/unitto/data/units/collections/Torque.kt b/data/units/src/main/java/com/sadellie/unitto/data/units/collections/Torque.kt index 867391f1..b1752807 100644 --- a/data/units/src/main/java/com/sadellie/unitto/data/units/collections/Torque.kt +++ b/data/units/src/main/java/com/sadellie/unitto/data/units/collections/Torque.kt @@ -18,11 +18,11 @@ package com.sadellie.unitto.data.units.collections +import com.sadellie.unitto.core.base.R import com.sadellie.unitto.data.model.AbstractUnit import com.sadellie.unitto.data.model.DefaultUnit import com.sadellie.unitto.data.model.UnitGroup import com.sadellie.unitto.data.units.MyUnitIDS -import com.sadellie.unitto.data.units.R import java.math.BigDecimal val torqueCollection: List by lazy { diff --git a/data/units/src/main/java/com/sadellie/unitto/data/units/collections/Volume.kt b/data/units/src/main/java/com/sadellie/unitto/data/units/collections/Volume.kt index 8e11d58a..51cac9e4 100644 --- a/data/units/src/main/java/com/sadellie/unitto/data/units/collections/Volume.kt +++ b/data/units/src/main/java/com/sadellie/unitto/data/units/collections/Volume.kt @@ -18,11 +18,11 @@ package com.sadellie.unitto.data.units.collections +import com.sadellie.unitto.core.base.R import com.sadellie.unitto.data.model.AbstractUnit import com.sadellie.unitto.data.model.DefaultUnit import com.sadellie.unitto.data.model.UnitGroup import com.sadellie.unitto.data.units.MyUnitIDS -import com.sadellie.unitto.data.units.R import java.math.BigDecimal internal val volumeCollection: List by lazy { 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 7edc7036..623e4bbb 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 @@ -62,6 +62,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.sadellie.unitto.core.base.R import com.sadellie.unitto.core.base.Separator import com.sadellie.unitto.core.ui.Formatter import com.sadellie.unitto.core.ui.common.MenuButton @@ -73,7 +74,7 @@ import com.sadellie.unitto.feature.calculator.components.DragDownView import com.sadellie.unitto.feature.calculator.components.HistoryList import kotlinx.coroutines.launch import java.text.SimpleDateFormat -import java.util.* +import java.util.Locale import kotlin.math.abs import kotlin.math.roundToInt 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 071cf31e..8ad0f27d 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 @@ -54,14 +54,14 @@ 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 com.sadellie.unitto.core.base.R import com.sadellie.unitto.core.ui.Formatter import com.sadellie.unitto.core.ui.common.textfield.UnittoTextToolbar import com.sadellie.unitto.core.ui.common.textfield.copyWithoutGrouping import com.sadellie.unitto.core.ui.theme.NumbersTextStyleDisplayMedium import com.sadellie.unitto.data.model.HistoryItem -import com.sadellie.unitto.feature.calculator.R import java.text.SimpleDateFormat -import java.util.* +import java.util.Locale @Composable internal fun HistoryList( diff --git a/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/ConverterScreen.kt b/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/ConverterScreen.kt index 6ec8c0be..fde2ff2a 100644 --- a/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/ConverterScreen.kt +++ b/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/ConverterScreen.kt @@ -35,7 +35,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.sadellie.unitto.core.ui.R +import com.sadellie.unitto.core.base.R import com.sadellie.unitto.core.ui.common.MenuButton import com.sadellie.unitto.core.ui.common.PortraitLandscape import com.sadellie.unitto.core.ui.common.UnittoScreenWithTopBar diff --git a/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/components/MyTextField.kt b/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/components/MyTextField.kt index e344d063..41c71072 100644 --- a/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/components/MyTextField.kt +++ b/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/components/MyTextField.kt @@ -27,7 +27,7 @@ import androidx.compose.animation.expandVertically import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkVertically -import androidx.compose.animation.with +import androidx.compose.animation.togetherWith import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement @@ -50,7 +50,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import com.sadellie.unitto.core.ui.R +import com.sadellie.unitto.core.base.R import com.sadellie.unitto.core.ui.common.textfield.InputTextField import com.sadellie.unitto.core.ui.theme.NumbersTextStyleDisplayLarge @@ -105,9 +105,9 @@ internal fun MyTextField( targetState = textToShow, transitionSpec = { // Enter animation - (expandHorizontally(clip = false, expandFrom = Alignment.Start) + fadeIn() + ((expandHorizontally(clip = false, expandFrom = Alignment.Start) + fadeIn() // Exit animation - with fadeOut()) + ).togetherWith(fadeOut())) .using(SizeTransform(clip = false)) } ) { @@ -138,9 +138,9 @@ internal fun MyTextField( targetState = secondaryText, transitionSpec = { // Enter animation - (expandHorizontally(clip = false, expandFrom = Alignment.Start) + fadeIn() + ((expandHorizontally(clip = false, expandFrom = Alignment.Start) + fadeIn() // Exit animation - with fadeOut()) + ).togetherWith(fadeOut())) .using(SizeTransform(clip = false)) } ) { diff --git a/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/components/TopScreen.kt b/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/components/TopScreen.kt index 39f1af65..c56e8898 100644 --- a/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/components/TopScreen.kt +++ b/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/components/TopScreen.kt @@ -29,7 +29,7 @@ import androidx.compose.animation.expandVertically import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkVertically -import androidx.compose.animation.with +import androidx.compose.animation.togetherWith import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth @@ -51,8 +51,8 @@ import androidx.compose.ui.draw.rotate import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign +import com.sadellie.unitto.core.base.R import com.sadellie.unitto.core.ui.Formatter -import com.sadellie.unitto.core.ui.R import com.sadellie.unitto.core.ui.common.ColumnWithConstraints import com.sadellie.unitto.core.ui.common.textfield.InputTextField import com.sadellie.unitto.data.model.AbstractUnit @@ -129,9 +129,9 @@ internal fun TopScreenPart( targetState = stringResource(unitFrom?.shortName ?: R.string.loading_label), transitionSpec = { // Enter animation - (expandHorizontally(clip = false, expandFrom = Alignment.Start) + fadeIn() + ((expandHorizontally(clip = false, expandFrom = Alignment.Start) + fadeIn() // Exit animation - with fadeOut()) + ).togetherWith(fadeOut())) .using(SizeTransform(clip = false)) } ) { value -> @@ -164,9 +164,9 @@ internal fun TopScreenPart( targetState = stringResource(unitTo?.shortName ?: R.string.loading_label), transitionSpec = { // Enter animation - (expandHorizontally(clip = false, expandFrom = Alignment.Start) + fadeIn() + ((expandHorizontally(clip = false, expandFrom = Alignment.Start) + fadeIn() // Exit animation - with fadeOut()) + ).togetherWith(fadeOut())) .using(SizeTransform(clip = false)) } ) { value -> diff --git a/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/components/UnitSelectionButton.kt b/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/components/UnitSelectionButton.kt index 52cf5912..799eacfb 100644 --- a/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/components/UnitSelectionButton.kt +++ b/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/components/UnitSelectionButton.kt @@ -18,6 +18,7 @@ package com.sadellie.unitto.feature.converter.components +import android.annotation.SuppressLint import androidx.compose.animation.AnimatedContent import androidx.compose.animation.SizeTransform import androidx.compose.animation.core.FastOutSlowInEasing @@ -27,7 +28,7 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically -import androidx.compose.animation.with +import androidx.compose.animation.togetherWith import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.layout.PaddingValues @@ -43,7 +44,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import com.sadellie.unitto.core.ui.R +import com.sadellie.unitto.core.base.R /** * Button to select a unit @@ -52,6 +53,7 @@ import com.sadellie.unitto.core.ui.R * @param onClick Function to call when button is clicked (navigate to a unit selection screen) * @param label Text on button */ +@SuppressLint("UnusedContentLambdaTargetStateParameter") @Composable internal fun UnitSelectionButton( modifier: Modifier = Modifier, @@ -80,11 +82,11 @@ internal fun UnitSelectionButton( targetState = label ?: 0, transitionSpec = { if (targetState > initialState) { - slideInVertically { height -> height } + fadeIn() with - slideOutVertically { height -> -height } + fadeOut() + (slideInVertically { height -> height } + fadeIn()).togetherWith( + slideOutVertically { height -> -height } + fadeOut()) } else { - slideInVertically { height -> -height } + fadeIn() with - slideOutVertically { height -> height } + fadeOut() + (slideInVertically { height -> -height } + fadeIn()).togetherWith( + slideOutVertically { height -> height } + fadeOut()) }.using( SizeTransform(clip = false) ) diff --git a/feature/epoch/src/main/java/com/sadellie/unitto/feature/epoch/EpochScreen.kt b/feature/epoch/src/main/java/com/sadellie/unitto/feature/epoch/EpochScreen.kt index 0c1b0286..a8d17e30 100644 --- a/feature/epoch/src/main/java/com/sadellie/unitto/feature/epoch/EpochScreen.kt +++ b/feature/epoch/src/main/java/com/sadellie/unitto/feature/epoch/EpochScreen.kt @@ -27,6 +27,7 @@ import androidx.compose.ui.text.TextRange import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.sadellie.unitto.core.base.R import com.sadellie.unitto.core.ui.common.MenuButton import com.sadellie.unitto.core.ui.common.PortraitLandscape import com.sadellie.unitto.core.ui.common.UnittoScreenWithTopBar diff --git a/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/AboutScreen.kt b/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/AboutScreen.kt index 7e9fd9e3..00ad327e 100644 --- a/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/AboutScreen.kt +++ b/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/AboutScreen.kt @@ -45,7 +45,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.sadellie.unitto.core.base.BuildConfig -import com.sadellie.unitto.core.ui.R +import com.sadellie.unitto.core.base.R import com.sadellie.unitto.core.ui.common.NavigateUpButton import com.sadellie.unitto.core.ui.common.UnittoScreenWithLargeTopBar import com.sadellie.unitto.core.ui.openLink diff --git a/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/SettingsScreen.kt b/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/SettingsScreen.kt index ece6aa4b..122588e6 100644 --- a/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/SettingsScreen.kt +++ b/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/SettingsScreen.kt @@ -47,9 +47,9 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.sadellie.unitto.core.base.BuildConfig import com.sadellie.unitto.core.base.OUTPUT_FORMAT import com.sadellie.unitto.core.base.PRECISIONS +import com.sadellie.unitto.core.base.R import com.sadellie.unitto.core.base.SEPARATORS import com.sadellie.unitto.core.base.TOP_LEVEL_DESTINATIONS -import com.sadellie.unitto.core.ui.R import com.sadellie.unitto.core.ui.common.Header import com.sadellie.unitto.core.ui.common.MenuButton import com.sadellie.unitto.core.ui.common.UnittoListItem diff --git a/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/ThemesScreen.kt b/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/ThemesScreen.kt index 720b4393..72a49460 100644 --- a/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/ThemesScreen.kt +++ b/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/ThemesScreen.kt @@ -49,7 +49,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import com.sadellie.unitto.core.ui.R +import com.sadellie.unitto.core.base.R import com.sadellie.unitto.core.ui.common.Header import com.sadellie.unitto.core.ui.common.NavigateUpButton import com.sadellie.unitto.core.ui.common.SegmentedButton diff --git a/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/ThirdPartyLicensesScreen.kt b/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/ThirdPartyLicensesScreen.kt index bb00bc3c..2bba1fe2 100644 --- a/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/ThirdPartyLicensesScreen.kt +++ b/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/ThirdPartyLicensesScreen.kt @@ -36,7 +36,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import com.sadellie.unitto.core.ui.R +import com.sadellie.unitto.core.base.R import com.sadellie.unitto.core.ui.common.NavigateUpButton import com.sadellie.unitto.core.ui.common.UnittoScreenWithLargeTopBar import com.sadellie.unitto.core.ui.openLink diff --git a/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/UnitGroupsScreen.kt b/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/UnitGroupsScreen.kt index ed93cd2e..39e6e148 100644 --- a/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/UnitGroupsScreen.kt +++ b/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/UnitGroupsScreen.kt @@ -47,10 +47,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import com.sadellie.unitto.core.base.R import com.sadellie.unitto.core.ui.common.Header -import com.sadellie.unitto.core.ui.common.UnittoScreenWithLargeTopBar -import com.sadellie.unitto.core.ui.R import com.sadellie.unitto.core.ui.common.NavigateUpButton +import com.sadellie.unitto.core.ui.common.UnittoScreenWithLargeTopBar import org.burnoutcrew.reorderable.ReorderableItem import org.burnoutcrew.reorderable.detectReorder import org.burnoutcrew.reorderable.detectReorderAfterLongPress diff --git a/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/components/AlertDialogWithList.kt b/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/components/AlertDialogWithList.kt index 179d669f..6eda5080 100644 --- a/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/components/AlertDialogWithList.kt +++ b/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/components/AlertDialogWithList.kt @@ -38,7 +38,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import com.sadellie.unitto.feature.settings.R +import com.sadellie.unitto.core.base.R /** * Alert dialog that has a list of options in it diff --git a/feature/unitslist/src/main/java/com/sadellie/unitto/feature/unitslist/LeftSideScreen.kt b/feature/unitslist/src/main/java/com/sadellie/unitto/feature/unitslist/LeftSideScreen.kt index 19d47686..3ed76063 100644 --- a/feature/unitslist/src/main/java/com/sadellie/unitto/feature/unitslist/LeftSideScreen.kt +++ b/feature/unitslist/src/main/java/com/sadellie/unitto/feature/unitslist/LeftSideScreen.kt @@ -45,6 +45,7 @@ import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.sadellie.unitto.core.base.R import com.sadellie.unitto.data.model.AbstractUnit import com.sadellie.unitto.feature.unitslist.components.ChipsRow import com.sadellie.unitto.feature.unitslist.components.SearchBar diff --git a/feature/unitslist/src/main/java/com/sadellie/unitto/feature/unitslist/RightSideScreen.kt b/feature/unitslist/src/main/java/com/sadellie/unitto/feature/unitslist/RightSideScreen.kt index 21f94644..1f3ae6a5 100644 --- a/feature/unitslist/src/main/java/com/sadellie/unitto/feature/unitslist/RightSideScreen.kt +++ b/feature/unitslist/src/main/java/com/sadellie/unitto/feature/unitslist/RightSideScreen.kt @@ -32,6 +32,7 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.stringResource import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.sadellie.unitto.core.base.R import com.sadellie.unitto.core.ui.Formatter import com.sadellie.unitto.data.model.AbstractUnit import com.sadellie.unitto.data.model.NumberBaseUnit diff --git a/feature/unitslist/src/main/java/com/sadellie/unitto/feature/unitslist/components/ChipsRow.kt b/feature/unitslist/src/main/java/com/sadellie/unitto/feature/unitslist/components/ChipsRow.kt index 4a7e362b..2261fbb0 100644 --- a/feature/unitslist/src/main/java/com/sadellie/unitto/feature/unitslist/components/ChipsRow.kt +++ b/feature/unitslist/src/main/java/com/sadellie/unitto/feature/unitslist/components/ChipsRow.kt @@ -46,9 +46,9 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import com.sadellie.unitto.core.base.R import com.sadellie.unitto.data.model.ALL_UNIT_GROUPS import com.sadellie.unitto.data.model.UnitGroup -import com.sadellie.unitto.feature.unitslist.R /** * Row of chips with [UnitGroup]s. Temporary solution diff --git a/feature/unitslist/src/main/java/com/sadellie/unitto/feature/unitslist/components/SearchBar.kt b/feature/unitslist/src/main/java/com/sadellie/unitto/feature/unitslist/components/SearchBar.kt index 7cee6021..6e78872c 100644 --- a/feature/unitslist/src/main/java/com/sadellie/unitto/feature/unitslist/components/SearchBar.kt +++ b/feature/unitslist/src/main/java/com/sadellie/unitto/feature/unitslist/components/SearchBar.kt @@ -18,6 +18,7 @@ package com.sadellie.unitto.feature.unitslist.components +import android.annotation.SuppressLint import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility @@ -27,7 +28,7 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleOut -import androidx.compose.animation.with +import androidx.compose.animation.togetherWith import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.text.BasicTextField @@ -61,8 +62,8 @@ import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction +import com.sadellie.unitto.core.base.R import com.sadellie.unitto.core.ui.common.NavigateUpButton -import com.sadellie.unitto.feature.unitslist.R /** * Search bar on the Second screen. Controls what will be shown in the list above this component @@ -214,6 +215,7 @@ private fun SearchButton( } } +@SuppressLint("UnusedContentLambdaTargetStateParameter") @Composable private fun FavoritesButton( favoritesOnly: Boolean, @@ -223,7 +225,7 @@ private fun FavoritesButton( AnimatedContent( targetState = favoritesOnly, transitionSpec = { - (scaleIn() with scaleOut()).using(SizeTransform(clip = false)) + (scaleIn() togetherWith scaleOut()).using(SizeTransform(clip = false)) } ) { Icon( diff --git a/feature/unitslist/src/main/java/com/sadellie/unitto/feature/unitslist/components/SearchPlaceholder.kt b/feature/unitslist/src/main/java/com/sadellie/unitto/feature/unitslist/components/SearchPlaceholder.kt index 0f8abbba..c0c72c38 100644 --- a/feature/unitslist/src/main/java/com/sadellie/unitto/feature/unitslist/components/SearchPlaceholder.kt +++ b/feature/unitslist/src/main/java/com/sadellie/unitto/feature/unitslist/components/SearchPlaceholder.kt @@ -35,7 +35,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import com.sadellie.unitto.feature.unitslist.R +import com.sadellie.unitto.core.base.R /** * Placeholder that can be seen when there are no units found diff --git a/feature/unitslist/src/main/java/com/sadellie/unitto/feature/unitslist/components/UnitListItem.kt b/feature/unitslist/src/main/java/com/sadellie/unitto/feature/unitslist/components/UnitListItem.kt index ba5207f4..29fbb3d7 100644 --- a/feature/unitslist/src/main/java/com/sadellie/unitto/feature/unitslist/components/UnitListItem.kt +++ b/feature/unitslist/src/main/java/com/sadellie/unitto/feature/unitslist/components/UnitListItem.kt @@ -18,11 +18,12 @@ package com.sadellie.unitto.feature.unitslist.components +import android.annotation.SuppressLint import androidx.compose.animation.AnimatedContent import androidx.compose.animation.SizeTransform import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleOut -import androidx.compose.animation.with +import androidx.compose.animation.togetherWith import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource @@ -53,8 +54,8 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import com.sadellie.unitto.core.base.R import com.sadellie.unitto.data.model.AbstractUnit -import com.sadellie.unitto.feature.unitslist.R /** * Represents one list item. Once clicked will navigate up. @@ -65,6 +66,7 @@ import com.sadellie.unitto.feature.unitslist.R * @param favoriteAction Function to mark unit as favorite. It's a toggle. * @param shortNameLabel String on the second line. */ +@SuppressLint("UnusedContentLambdaTargetStateParameter") @Composable private fun BasicUnitListItem( modifier: Modifier, @@ -124,7 +126,7 @@ private fun BasicUnitListItem( ), targetState = isFavorite, transitionSpec = { - (scaleIn() with scaleOut()).using(SizeTransform(clip = false)) + (scaleIn() togetherWith scaleOut()).using(SizeTransform(clip = false)) } ) { Icon( diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f5ec2f09..e01bca8f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,19 +1,19 @@ [versions] appCode = "20" appName = "Kobicha" -kotlin = "1.8.10" +kotlin = "1.8.21" androidxCore = "1.10.0" -androidGradlePlugin = "7.4.2" +androidGradlePlugin = "8.0.1" orgJetbrainsKotlinxCoroutinesTest = "1.6.4" androidxCompose = "1.5.0-alpha02" -androidxComposeCompiler = "1.4.4" -androidxComposeUi = "1.5.0-alpha02" -androidxComposeMaterial3 = "1.1.0-beta02" +androidxComposeCompiler = "1.4.7" +androidxComposeUi = "1.5.0-alpha04" +androidxComposeMaterial3 = "1.2.0-alpha01" androidxNavigation = "2.5.3" androidxLifecycleRuntimeCompose = "2.6.1" androidxHilt = "1.0.0" comGoogleDagger = "2.45" -androidxComposeMaterialIconsExtended = "1.5.0-alpha02" +androidxComposeMaterialIconsExtended = "1.5.0-alpha04" androidxDatastore = "1.0.0" comGoogleAccompanist = "0.30.1" androidxRoom = "2.5.1" diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index f16f7433..6c8fc19d 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ #Thu Feb 02 22:43:30 AZT 2023 distributionBase=GRADLE_USER_HOME -distributionUrl=https\://services.gradle.org/distributions/gradle-7.5.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.1-bin.zip distributionPath=wrapper/dists zipStorePath=wrapper/dists zipStoreBase=GRADLE_USER_HOME From f25af1caadae9a38b6d89371257a4a2b956ca7d9 Mon Sep 17 00:00:00 2001 From: Sad Ellie Date: Mon, 15 May 2023 16:48:05 +0300 Subject: [PATCH 04/51] Less trash in code --- build-logic/settings.gradle.kts | 1 + .../unitto/core/base/TopLevelDestinations.kt | 8 +++--- .../ic_launcher_icon_round.xml | 24 ------------------ .../mipmap-hdpi/ic_launcher_icon_round.png | Bin 4172 -> 0 bytes .../mipmap-mdpi/ic_launcher_icon_round.png | Bin 2569 -> 0 bytes .../mipmap-xhdpi/ic_launcher_icon_round.png | Bin 5927 -> 0 bytes .../mipmap-xxhdpi/ic_launcher_icon_round.png | Bin 9767 -> 0 bytes .../mipmap-xxxhdpi/ic_launcher_icon_round.png | Bin 14472 -> 0 bytes .../ui/common/textfield/FormatterSymbols.kt | 4 +-- .../feature/converter/components/TopScreen.kt | 23 +++++++++++------ .../components/UnitSelectionButton.kt | 8 +++--- settings.gradle.kts | 1 + 12 files changed, 27 insertions(+), 42 deletions(-) delete mode 100644 core/base/src/main/res/mipmap-anydpi-v26/ic_launcher_icon_round.xml delete mode 100644 core/base/src/main/res/mipmap-hdpi/ic_launcher_icon_round.png delete mode 100644 core/base/src/main/res/mipmap-mdpi/ic_launcher_icon_round.png delete mode 100644 core/base/src/main/res/mipmap-xhdpi/ic_launcher_icon_round.png delete mode 100644 core/base/src/main/res/mipmap-xxhdpi/ic_launcher_icon_round.png delete mode 100644 core/base/src/main/res/mipmap-xxxhdpi/ic_launcher_icon_round.png diff --git a/build-logic/settings.gradle.kts b/build-logic/settings.gradle.kts index b92e00da..9effadbc 100644 --- a/build-logic/settings.gradle.kts +++ b/build-logic/settings.gradle.kts @@ -16,6 +16,7 @@ * along with this program. If not, see . */ +@Suppress("UnstableApiUsage") dependencyResolutionManagement { repositories { google() diff --git a/core/base/src/main/java/com/sadellie/unitto/core/base/TopLevelDestinations.kt b/core/base/src/main/java/com/sadellie/unitto/core/base/TopLevelDestinations.kt index 5d4bff94..fe6b3bb8 100644 --- a/core/base/src/main/java/com/sadellie/unitto/core/base/TopLevelDestinations.kt +++ b/core/base/src/main/java/com/sadellie/unitto/core/base/TopLevelDestinations.kt @@ -34,10 +34,10 @@ sealed class TopLevelDestinations( name = R.string.calculator ) - object Epoch : TopLevelDestinations( - route = "epoch_route", - name = R.string.epoch_converter - ) +// object Epoch : TopLevelDestinations( +// route = "epoch_route", +// name = R.string.epoch_converter +// ) object Settings : TopLevelDestinations( route = "settings_graph", diff --git a/core/base/src/main/res/mipmap-anydpi-v26/ic_launcher_icon_round.xml b/core/base/src/main/res/mipmap-anydpi-v26/ic_launcher_icon_round.xml deleted file mode 100644 index cfabe93f..00000000 --- a/core/base/src/main/res/mipmap-anydpi-v26/ic_launcher_icon_round.xml +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/core/base/src/main/res/mipmap-hdpi/ic_launcher_icon_round.png b/core/base/src/main/res/mipmap-hdpi/ic_launcher_icon_round.png deleted file mode 100644 index 58645901c0ce9cf25c5d1fc09b7631344734a248..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4172 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 Wk3TSgf8#Fz00000mA{a|W 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 diff --git a/core/base/src/main/res/mipmap-xhdpi/ic_launcher_icon_round.png b/core/base/src/main/res/mipmap-xhdpi/ic_launcher_icon_round.png deleted file mode 100644 index ea63bc688ad029cd2c29aed72fe06b9bc0465445..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5927 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 JPDHLkV1k!~YRmut diff --git a/core/base/src/main/res/mipmap-xxhdpi/ic_launcher_icon_round.png b/core/base/src/main/res/mipmap-xxhdpi/ic_launcher_icon_round.png deleted file mode 100644 index 2d86de894ec1d4d498dab1c8800548bc79bde0d8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 9767 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 Bq+S33 diff --git a/core/base/src/main/res/mipmap-xxxhdpi/ic_launcher_icon_round.png b/core/base/src/main/res/mipmap-xxxhdpi/ic_launcher_icon_round.png deleted file mode 100644 index 16dc1a5e4e0c98c666b8d99ad204b49f25238caa..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 14472 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 ZQ0XtCcWJdM + calculatedTextFieldValue = calculatedTextFieldValue.copy(selection = newSelection) + }, formatterSymbols = formatterSymbols, textColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), minRatio = 0.7f @@ -159,8 +161,7 @@ internal fun TopScreenPart( transitionSpec = { // Enter animation (expandHorizontally(clip = false, expandFrom = Alignment.Start) + fadeIn() - // Exit animation - with fadeOut()) + togetherWith fadeOut()) .using(SizeTransform(clip = false)) } ) { value -> @@ -178,7 +179,9 @@ internal fun TopScreenPart( ExpressionTextField( modifier = Modifier.weight(2f), value = outputTextFieldValue, - onCursorChange = { outputTextFieldValue = outputTextFieldValue.copy(selection = it) }, + onCursorChange = { newSelection -> + outputTextFieldValue = outputTextFieldValue.copy(selection = newSelection) + }, formatterSymbols = formatterSymbols, readOnly = true, minRatio = 0.7f @@ -197,7 +200,9 @@ internal fun TopScreenPart( UnformattedTextField( modifier = Modifier.weight(2f), value = outputTextFieldValue, - onCursorChange = { outputTextFieldValue = outputTextFieldValue.copy(selection = it) }, + onCursorChange = { newSelection -> + outputTextFieldValue = outputTextFieldValue.copy(selection = newSelection) + }, minRatio = 0.7f, readOnly = true ) @@ -210,7 +215,9 @@ internal fun TopScreenPart( UnformattedTextField( modifier = Modifier.weight(2f), value = outputTextFieldValue, - onCursorChange = { outputTextFieldValue = outputTextFieldValue.copy(selection = it) }, + onCursorChange = { newSelection -> + outputTextFieldValue = outputTextFieldValue.copy(selection = newSelection) + }, minRatio = 0.7f, readOnly = true ) @@ -245,7 +252,7 @@ internal fun TopScreenPart( // Enter animation (expandHorizontally(clip = false, expandFrom = Alignment.Start) + fadeIn() // Exit animation - with fadeOut()) + togetherWith fadeOut()) .using(SizeTransform(clip = false)) } ) { value -> diff --git a/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/components/UnitSelectionButton.kt b/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/components/UnitSelectionButton.kt index 799eacfb..65a03503 100644 --- a/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/components/UnitSelectionButton.kt +++ b/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/components/UnitSelectionButton.kt @@ -82,11 +82,11 @@ internal fun UnitSelectionButton( targetState = label ?: 0, transitionSpec = { if (targetState > initialState) { - (slideInVertically { height -> height } + fadeIn()).togetherWith( - slideOutVertically { height -> -height } + fadeOut()) + (slideInVertically { height -> height } + fadeIn()) togetherWith + slideOutVertically { height -> -height } + fadeOut() } else { - (slideInVertically { height -> -height } + fadeIn()).togetherWith( - slideOutVertically { height -> height } + fadeOut()) + (slideInVertically { height -> -height } + fadeIn()) togetherWith + slideOutVertically { height -> height } + fadeOut() }.using( SizeTransform(clip = false) ) diff --git a/settings.gradle.kts b/settings.gradle.kts index 7e9e80f9..4f3cc9f0 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -7,6 +7,7 @@ pluginManagement { } } +@Suppress("UnstableApiUsage") dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositories { From c838d0f32dab1c3e7cfc931bffeebc1955fe037d Mon Sep 17 00:00:00 2001 From: Sad Ellie Date: Tue, 16 May 2023 09:59:43 +0300 Subject: [PATCH 05/51] Fix delayed clear history button --- .../feature/calculator/CalculatorScreen.kt | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) 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 12353b67..64780364 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 @@ -46,6 +46,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -113,16 +114,18 @@ private fun CalculatorScreen( evaluate: () -> Unit, clearHistory: () -> Unit ) { - var showClearHistoryButton by rememberSaveable { mutableStateOf(false) } - var showClearHistoryDialog by rememberSaveable { mutableStateOf(false) } - val dragAmount = remember { Animatable(0f) } val dragCoroutineScope = rememberCoroutineScope() - val dragAnimDecay = rememberSplineBasedDecay() + val dragAnimSpec = rememberSplineBasedDecay() var textThingyHeight by remember { mutableStateOf(0) } var historyItemHeight by remember { mutableStateOf(0) } + var showClearHistoryDialog by rememberSaveable { mutableStateOf(false) } + val showClearHistoryButton by remember(dragAmount.value, historyItemHeight) { + derivedStateOf { dragAmount.value > historyItemHeight } + } + UnittoScreenWithTopBar( title = { Text(stringResource(R.string.calculator)) }, navigationIcon = { MenuButton { navigateToMenu() } }, @@ -187,15 +190,11 @@ private fun CalculatorScreen( }, onDragStopped = { velocity -> dragCoroutineScope.launch { - dragAmount.animateDecay(velocity, dragAnimDecay) + dragAmount.animateDecay(velocity, dragAnimSpec) // Snap to closest anchor (0, one history item, all history items) val draggedAmount = listOf(0, historyItemHeight, maxDragAmount) .minBy { abs(dragAmount.value.roundToInt() - it) } - .also { - // Show button only when fully history view is fully expanded - showClearHistoryButton = it == maxDragAmount - } .toFloat() dragAmount.animateTo(draggedAmount) } From 5932332fc8709917fd85a250c72efcf91e9a366e Mon Sep 17 00:00:00 2001 From: Sad Ellie Date: Tue, 16 May 2023 11:13:24 +0300 Subject: [PATCH 06/51] Fix crash on empty input in unit converter --- .../sadellie/unitto/feature/converter/components/TopScreen.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/components/TopScreen.kt b/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/components/TopScreen.kt index f73a2bd5..31507ee5 100644 --- a/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/components/TopScreen.kt +++ b/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/components/TopScreen.kt @@ -301,7 +301,7 @@ internal fun TopScreenPart( navigateToRightScreen( unitFrom.unitId, unitTo.unitId, - input + input?.ifEmpty { "0" } ) }, label = unitTo?.displayName ?: R.string.loading_label, From 533a281af9c4c13043d6caf68a3d53cdd1c33319 Mon Sep 17 00:00:00 2001 From: Sad Ellie Date: Wed, 17 May 2023 10:27:15 +0300 Subject: [PATCH 07/51] Fix cursor position for separators --- .../common/textfield/ExpressionTransformer.kt | 34 +++++++++++++++---- 1 file changed, 27 insertions(+), 7 deletions(-) diff --git a/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/textfield/ExpressionTransformer.kt b/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/textfield/ExpressionTransformer.kt index eb59a8c6..ce2df4fe 100644 --- a/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/textfield/ExpressionTransformer.kt +++ b/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/textfield/ExpressionTransformer.kt @@ -37,15 +37,37 @@ class ExpressionTransformer(private val formatterSymbols: FormatterSymbols) : Vi private val unformatted: String, private val formatted: String ) : OffsetMapping { - // Called when entering text (on each text change) // Basically moves cursor to the right position // // original input is "1000" and cursor is placed at the end "1000|" // the formatted is "1,000" where cursor should be? - "1,000|" override fun originalToTransformed(offset: Int): Int { + val grouping = formatterSymbols.grouping.first() val fixedCursor = unformatted.fixCursor(offset, formatterSymbols.grouping) - return fixedCursor + countAddedSymbolsBeforeCursor(fixedCursor) + + // Unformatted always has "." (dot) as a fractional, we can't ues for checking with buffer, + // because it will fail when fractional is "," (comma) + val subString = formatted + .replace(formatterSymbols.grouping, "") + .replace(formatterSymbols.fractional, ".") + .take(offset) + var buffer = "" + var groupingCount = 0 + var cursor = 0 + + // we walk over formatted and stop when it matches unformatted (also counting grouping) + while (buffer != subString) { + val currentChar = formatted[cursor] + if (currentChar == grouping) { + groupingCount += 1 + } else { + buffer += currentChar + } + cursor++ + } + + return fixedCursor + groupingCount } // Called when clicking formatted text @@ -54,12 +76,10 @@ class ExpressionTransformer(private val formatterSymbols: FormatterSymbols) : Vi // the formatted is "1,000" and cursor is placed at the end "1,000|" // original input is "1000" where cursor should be? - "1000|" override fun transformedToOriginal(offset: Int): Int { + val grouping = formatterSymbols.grouping.first() val fixedCursor = formatted.fixCursor(offset, formatterSymbols.grouping) - return fixedCursor - countAddedSymbolsBeforeCursor(fixedCursor) - } - - private fun countAddedSymbolsBeforeCursor(cursor: Int): Int { - return formatted.take(cursor).count { it.toString() == formatterSymbols.grouping } + val addedSymbols = formatted.take(fixedCursor).count { it == grouping } + return fixedCursor - addedSymbols } } } From 777ff6ca67eab3069b253c3eae2de2fddc59eb27 Mon Sep 17 00:00:00 2001 From: Sad Ellie Date: Wed, 17 May 2023 10:28:05 +0300 Subject: [PATCH 08/51] Refactor HistoryList composable --- .../data/calculator/CalculatorRepository.kt | 1 + .../sadellie/unitto/data/model/HistoryItem.kt | 3 +- .../feature/calculator/CalculatorScreen.kt | 1 + .../calculator/components/HistoryList.kt | 108 +++++++++++------- 4 files changed, 71 insertions(+), 42 deletions(-) diff --git a/data/calculator/src/main/java/com/sadellie/unitto/data/calculator/CalculatorRepository.kt b/data/calculator/src/main/java/com/sadellie/unitto/data/calculator/CalculatorRepository.kt index 54576a03..743d4576 100644 --- a/data/calculator/src/main/java/com/sadellie/unitto/data/calculator/CalculatorRepository.kt +++ b/data/calculator/src/main/java/com/sadellie/unitto/data/calculator/CalculatorRepository.kt @@ -59,6 +59,7 @@ class CalculatorHistoryRepository @Inject constructor( private fun List.toHistoryItemList(): List { return this.map { HistoryItem( + id = it.entityId, date = Date(it.timestamp), expression = it.expression, result = it.result diff --git a/data/model/src/main/java/com/sadellie/unitto/data/model/HistoryItem.kt b/data/model/src/main/java/com/sadellie/unitto/data/model/HistoryItem.kt index f6ab18a6..dc56992c 100644 --- a/data/model/src/main/java/com/sadellie/unitto/data/model/HistoryItem.kt +++ b/data/model/src/main/java/com/sadellie/unitto/data/model/HistoryItem.kt @@ -18,9 +18,10 @@ package com.sadellie.unitto.data.model -import java.util.* +import java.util.Date data class HistoryItem( + val id: Int, val date: Date, val expression: String, val result: String 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 64780364..1523c83f 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 @@ -339,6 +339,7 @@ private fun PreviewCalculatorScreen() { "14.07.2005 23:59:19", ).map { HistoryItem( + id = it.hashCode(), date = dtf.parse(it)!!, expression = "12345".repeat(10), result = "1234" 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 cce095f5..9941b691 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 @@ -27,6 +27,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState @@ -39,7 +40,6 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -75,53 +75,78 @@ internal fun HistoryList( formatterSymbols: FormatterSymbols, addTokens: (String) -> Unit, ) { - val verticalArrangement by remember(historyItems) { - derivedStateOf { - if (historyItems.isEmpty()) { - Arrangement.Center - } else { - Arrangement.spacedBy(16.dp, Alignment.Bottom) - } + if (historyItems.isEmpty()) { + HistoryListPlaceholder( + modifier = modifier, + historyItemHeightCallback = historyItemHeightCallback + ) + } else { + HistoryListContent( + modifier = modifier, + historyItems = historyItems, + addTokens = addTokens, + formatterSymbols = formatterSymbols, + historyItemHeightCallback = historyItemHeightCallback + ) + } +} + +@Composable +private fun HistoryListPlaceholder( + modifier: Modifier, + historyItemHeightCallback: (Int) -> Unit +) { + Column( + modifier = modifier.wrapContentHeight(unbounded = true), + verticalArrangement = Arrangement.Center + ) { + Column( + modifier = Modifier + .onPlaced { historyItemHeightCallback(it.size.height) } + .fillMaxWidth() + .padding(vertical = 32.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon(Icons.Default.History, null) + Text(stringResource(R.string.calculator_no_history)) } } +} + +@Composable +private fun HistoryListContent( + modifier: Modifier, + historyItems: List, + addTokens: (String) -> Unit, + formatterSymbols: FormatterSymbols, + historyItemHeightCallback: (Int) -> Unit +) { + val firstItem by remember { mutableStateOf(historyItems.first()) } + val restOfTheItems by remember { mutableStateOf(historyItems.drop(1)) } LazyColumn( modifier = modifier, reverseLayout = true, - verticalArrangement = verticalArrangement + verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.Bottom) ) { - if (historyItems.isEmpty()) { - item { - Column( - modifier = Modifier - .onPlaced { historyItemHeightCallback(it.size.height) } - .fillParentMaxWidth() - .padding(vertical = 32.dp), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally - ) { - Icon(Icons.Default.History, null) - Text(stringResource(R.string.calculator_no_history)) - } - } - } else { - // We do this so that callback for items height is called only once - item { - HistoryListItem( - modifier = Modifier.onPlaced { historyItemHeightCallback(it.size.height) }, - historyItem = historyItems.first(), - formatterSymbols = formatterSymbols, - addTokens = addTokens, - ) - } - items(historyItems.drop(1)) { historyItem -> - HistoryListItem( - modifier = Modifier, - historyItem = historyItem, - formatterSymbols = formatterSymbols, - addTokens = addTokens, - ) - } + // We do this so that callback for items height is called only once + item(firstItem.id) { + HistoryListItem( + modifier = Modifier.onPlaced { historyItemHeightCallback(it.size.height) }, + historyItem = historyItems.first(), + formatterSymbols = formatterSymbols, + addTokens = addTokens, + ) + } + + items(restOfTheItems, { it.id }) { historyItem -> + HistoryListItem( + modifier = Modifier, + historyItem = historyItem, + formatterSymbols = formatterSymbols, + addTokens = addTokens, + ) } } } @@ -226,6 +251,7 @@ private fun PreviewHistoryList() { "14.07.2005 23:59:19", ).map { HistoryItem( + id = it.hashCode(), date = dtf.parse(it)!!, expression = "12345".repeat(10), result = "67890" From 52a1a5b4d5a7b1fbee6d28a613dbb5d0e983c3a2 Mon Sep 17 00:00:00 2001 From: Sad Ellie Date: Wed, 17 May 2023 11:05:45 +0300 Subject: [PATCH 09/51] Fix ignored minus --- .../core/ui/common/textfield/FormatterExtensions.kt | 2 +- .../unitto/feature/calculator/CalculatorViewModel.kt | 8 ++++++-- .../unitto/feature/calculator/components/HistoryList.kt | 5 +++-- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/textfield/FormatterExtensions.kt b/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/textfield/FormatterExtensions.kt index 818e528b..b9843698 100644 --- a/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/textfield/FormatterExtensions.kt +++ b/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/textfield/FormatterExtensions.kt @@ -39,7 +39,7 @@ private val timeDivisions by lazy { ) } -internal fun String.clearAndFilterExpression(formatterSymbols: FormatterSymbols): String { +fun String.clearAndFilterExpression(formatterSymbols: FormatterSymbols): String { var clean = this .replace(formatterSymbols.grouping, "") .replace(formatterSymbols.fractional, Token.Digit.dot) 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 7c12c7ad..79c15e10 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 @@ -22,6 +22,7 @@ import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.TextFieldValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.sadellie.unitto.core.base.Token import com.sadellie.unitto.core.ui.common.textfield.AllFormatterSymbols import com.sadellie.unitto.core.ui.common.textfield.addTokens import com.sadellie.unitto.core.ui.common.textfield.deleteTokens @@ -91,12 +92,15 @@ internal class CalculatorViewModel @Inject constructor( is CalculationResult.Default -> { if (calculationResult.text.isEmpty()) return@launch + // We can get negative number and they use ugly minus symbol + val calculationText = calculationResult.text.replace("-", Token.Operator.minus) + calculatorHistoryRepository.add( expression = _input.value.text, - result = calculationResult.text + result = calculationText ) _input.update { - TextFieldValue(calculationResult.text, TextRange(calculationResult.text.length)) + TextFieldValue(calculationText, TextRange(calculationText.length)) } _output.update { CalculationResult.Default() } } 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 9941b691..66cf68b7 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 @@ -61,6 +61,7 @@ import com.sadellie.unitto.core.base.R import com.sadellie.unitto.core.ui.common.textfield.ExpressionTransformer import com.sadellie.unitto.core.ui.common.textfield.FormatterSymbols import com.sadellie.unitto.core.ui.common.textfield.UnittoTextToolbar +import com.sadellie.unitto.core.ui.common.textfield.clearAndFilterExpression import com.sadellie.unitto.core.ui.common.textfield.copyWithoutGrouping import com.sadellie.unitto.core.ui.theme.NumbersTextStyleDisplayMedium import com.sadellie.unitto.data.model.HistoryItem @@ -171,14 +172,14 @@ private fun HistoryListItem( val expressionInteractionSource = remember(expression) { MutableInteractionSource() } LaunchedEffect(expressionInteractionSource) { expressionInteractionSource.interactions.collect { - if (it is PressInteraction.Release) addTokens(expression) + if (it is PressInteraction.Release) addTokens(expression.clearAndFilterExpression(formatterSymbols)) } } val resultInteractionSource = remember(result) { MutableInteractionSource() } LaunchedEffect(resultInteractionSource) { resultInteractionSource.interactions.collect { - if (it is PressInteraction.Release) addTokens(result) + if (it is PressInteraction.Release) addTokens(result.clearAndFilterExpression(formatterSymbols)) } } From d0b09beb7d6323fcd3ceb3e735881a21fefcdbcf Mon Sep 17 00:00:00 2001 From: Sad Ellie Date: Wed, 17 May 2023 12:21:09 +0300 Subject: [PATCH 10/51] Remove prints --- .../src/main/java/io/github/sadellie/evaluatto/Expression.kt | 2 -- .../src/test/java/io/github/sadellie/evaluatto/Helpers.kt | 3 +-- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/data/evaluatto/src/main/java/io/github/sadellie/evaluatto/Expression.kt b/data/evaluatto/src/main/java/io/github/sadellie/evaluatto/Expression.kt index 316c07d2..b39452e0 100644 --- a/data/evaluatto/src/main/java/io/github/sadellie/evaluatto/Expression.kt +++ b/data/evaluatto/src/main/java/io/github/sadellie/evaluatto/Expression.kt @@ -301,8 +301,6 @@ private fun BigDecimal.factorial(): BigDecimal { if (this.remainder(BigDecimal.ONE).compareTo(BigDecimal.ZERO) != 0) throw ExpressionException.FactorialCalculation() if (this < BigDecimal.ZERO) throw ExpressionException.FactorialCalculation() - println("got $this") - var expr = this for (i in 1 until this.toInt()) { expr *= BigDecimal(i) diff --git a/data/evaluatto/src/test/java/io/github/sadellie/evaluatto/Helpers.kt b/data/evaluatto/src/test/java/io/github/sadellie/evaluatto/Helpers.kt index 8cdc1468..1651ff2e 100644 --- a/data/evaluatto/src/test/java/io/github/sadellie/evaluatto/Helpers.kt +++ b/data/evaluatto/src/test/java/io/github/sadellie/evaluatto/Helpers.kt @@ -34,8 +34,7 @@ fun assertExprFail( radianMode: Boolean = true ) { Assert.assertThrows(expectedThrowable) { - val calculated = Expression(expr, radianMode = radianMode).calculate() - println(calculated) + Expression(expr, radianMode = radianMode).calculate() } } From 3a6987bac0a48b7b701feb78907673f9f2b07d14 Mon Sep 17 00:00:00 2001 From: Sad Ellie Date: Thu, 18 May 2023 22:34:21 +0300 Subject: [PATCH 11/51] squashable --- .../unitto/core/ui/common/KeyboardButton.kt | 35 +++----- .../core/ui/common/ModifierExtensions.kt | 83 +++++++++++++++++++ 2 files changed, 95 insertions(+), 23 deletions(-) create mode 100644 core/ui/src/main/java/com/sadellie/unitto/core/ui/common/ModifierExtensions.kt 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 f91e66af..aa4493b4 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 @@ -20,25 +20,18 @@ package com.sadellie.unitto.core.ui.common import android.content.res.Configuration import android.view.HapticFeedbackConstants -import androidx.compose.animation.core.FastOutSlowInEasing -import androidx.compose.animation.core.animateIntAsState -import androidx.compose.animation.core.tween -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalView +import kotlinx.coroutines.launch @Composable fun BasicKeyboardButton( @@ -52,21 +45,21 @@ fun BasicKeyboardButton( contentHeight: Float ) { 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), - ) + val coroutineScope = rememberCoroutineScope() UnittoButton( modifier = modifier, - onClick = onClick, + onClick = { + onClick() + if (allowVibration) { + coroutineScope.launch { + view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP) + } + } + }, onLongClick = onLongClick, - shape = RoundedCornerShape(cornerRadius), containerColor = containerColor, - contentPadding = PaddingValues(), - interactionSource = interactionSource + contentPadding = PaddingValues() ) { Icon( imageVector = icon, @@ -75,10 +68,6 @@ fun BasicKeyboardButton( tint = iconColor ) } - - LaunchedEffect(key1 = isPressed) { - if (isPressed and allowVibration) view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP) - } } @Composable diff --git a/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/ModifierExtensions.kt b/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/ModifierExtensions.kt new file mode 100644 index 00000000..bca4b685 --- /dev/null +++ b/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/ModifierExtensions.kt @@ -0,0 +1,83 @@ +/* + * 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.core.ui.common + +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateIntAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.ripple.rememberRipple +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.draw.clip +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.Dp + +fun Modifier.squashable( + onClick: () -> Unit = {}, + onLongClick: (() -> Unit)? = null, + interactionSource: MutableInteractionSource, + cornerRadiusRange: IntRange, + role: Role = Role.Button, +) = composed { + val isPressed by interactionSource.collectIsPressedAsState() + val cornerRadius: Int by animateIntAsState( + targetValue = if (isPressed) cornerRadiusRange.first else cornerRadiusRange.last, + animationSpec = tween(easing = FastOutSlowInEasing), + ) + + Modifier + .clip(RoundedCornerShape(cornerRadius)) + .combinedClickable( + onClick = onClick, + onLongClick = onLongClick, + interactionSource = interactionSource, + indication = rememberRipple(), + role = role, + ) +} + +fun Modifier.squashable( + onClick: () -> Unit = {}, + onLongClick: (() -> Unit)? = null, + interactionSource: MutableInteractionSource, + cornerRadiusRange: ClosedRange, + role: Role = Role.Button, +) = composed { + val isPressed by interactionSource.collectIsPressedAsState() + val cornerRadius: Dp by animateDpAsState( + targetValue = if (isPressed) cornerRadiusRange.start else cornerRadiusRange.endInclusive, + animationSpec = tween(easing = FastOutSlowInEasing), + ) + + Modifier + .clip(RoundedCornerShape(cornerRadius)) + .combinedClickable( + onClick = onClick, + onLongClick = onLongClick, + interactionSource = interactionSource, + indication = rememberRipple(), + role = role, + ) +} From 7aa0d3e43123a50103b134c1b8838023dc4521b4 Mon Sep 17 00:00:00 2001 From: Sad Ellie Date: Thu, 18 May 2023 23:28:05 +0300 Subject: [PATCH 12/51] Date difference tool --- app/build.gradle.kts | 2 +- .../java/com/sadellie/unitto/UnittoApp.kt | 6 +- .../com/sadellie/unitto/UnittoNavigation.kt | 6 +- .../unitto/core/base/TopLevelDestinations.kt | 8 +- core/base/src/main/res/values/strings.xml | 11 + .../core/ui/common/DateTimePickerDialog.kt | 280 ++++++++++++++++++ .../unitto/core/ui/common/UnittoButton.kt | 12 +- .../unitto/core/ui/model/DrawerItems.kt | 8 + feature/datedifference/.gitignore | 1 + feature/datedifference/build.gradle.kts | 37 +++ feature/datedifference/consumer-rules.pro | 0 .../src/main/AndroidManifest.xml | 22 ++ .../feature/datedifference/DateDifference.kt | 64 ++++ .../datedifference/DateDifferenceScreen.kt | 232 +++++++++++++++ .../datedifference/DateDifferenceViewModel.kt | 61 ++++ .../unitto/feature/datedifference/UIState.kt | 27 ++ .../components/DateTimeResultBlock.kt | 130 ++++++++ .../components/DateTimeSelectorBlock.kt | 109 +++++++ .../navigation/DateDifferenceNavigation.kt | 44 +++ settings.gradle.kts | 2 +- 20 files changed, 1044 insertions(+), 18 deletions(-) create mode 100644 core/ui/src/main/java/com/sadellie/unitto/core/ui/common/DateTimePickerDialog.kt create mode 100644 feature/datedifference/.gitignore create mode 100644 feature/datedifference/build.gradle.kts create mode 100644 feature/datedifference/consumer-rules.pro create mode 100644 feature/datedifference/src/main/AndroidManifest.xml create mode 100644 feature/datedifference/src/main/java/com/sadellie/unitto/feature/datedifference/DateDifference.kt create mode 100644 feature/datedifference/src/main/java/com/sadellie/unitto/feature/datedifference/DateDifferenceScreen.kt create mode 100644 feature/datedifference/src/main/java/com/sadellie/unitto/feature/datedifference/DateDifferenceViewModel.kt create mode 100644 feature/datedifference/src/main/java/com/sadellie/unitto/feature/datedifference/UIState.kt create mode 100644 feature/datedifference/src/main/java/com/sadellie/unitto/feature/datedifference/components/DateTimeResultBlock.kt create mode 100644 feature/datedifference/src/main/java/com/sadellie/unitto/feature/datedifference/components/DateTimeSelectorBlock.kt create mode 100644 feature/datedifference/src/main/java/com/sadellie/unitto/feature/datedifference/navigation/DateDifferenceNavigation.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 48ede545..89c279c8 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -120,7 +120,7 @@ dependencies { implementation(project(mapOf("path" to ":feature:calculator"))) implementation(project(mapOf("path" to ":feature:settings"))) implementation(project(mapOf("path" to ":feature:unitslist"))) - // implementation(project(mapOf("path" to ":feature:epoch"))) + implementation(project(mapOf("path" to ":feature:datedifference"))) implementation(project(mapOf("path" to ":data:units"))) implementation(project(mapOf("path" to ":data:model"))) implementation(project(mapOf("path" to ":data:userprefs"))) diff --git a/app/src/main/java/com/sadellie/unitto/UnittoApp.kt b/app/src/main/java/com/sadellie/unitto/UnittoApp.kt index 07ca79bc..c06d4c74 100644 --- a/app/src/main/java/com/sadellie/unitto/UnittoApp.kt +++ b/app/src/main/java/com/sadellie/unitto/UnittoApp.kt @@ -68,7 +68,11 @@ internal fun UnittoApp(userPrefs: UserPreferences) { // Navigation drawer stuff val drawerState = rememberDrawerState(DrawerValue.Closed) val drawerScope = rememberCoroutineScope() - val mainTabs = listOf(DrawerItems.Calculator, DrawerItems.Converter) + val mainTabs = listOf( + DrawerItems.Calculator, + DrawerItems.Converter, + DrawerItems.DateDifference + ) val additionalTabs = listOf(DrawerItems.Settings) val navBackStackEntry by navController.currentBackStackEntryAsState() diff --git a/app/src/main/java/com/sadellie/unitto/UnittoNavigation.kt b/app/src/main/java/com/sadellie/unitto/UnittoNavigation.kt index b5a6d1bb..c11bec89 100644 --- a/app/src/main/java/com/sadellie/unitto/UnittoNavigation.kt +++ b/app/src/main/java/com/sadellie/unitto/UnittoNavigation.kt @@ -29,6 +29,7 @@ import androidx.navigation.compose.NavHost import com.sadellie.unitto.feature.calculator.navigation.calculatorScreen import com.sadellie.unitto.feature.converter.ConverterViewModel import com.sadellie.unitto.feature.converter.navigation.converterScreen +import com.sadellie.unitto.feature.datedifference.navigation.dateDifferenceScreen import com.sadellie.unitto.feature.settings.SettingsViewModel import com.sadellie.unitto.feature.settings.navigation.navigateToSettings import com.sadellie.unitto.feature.settings.navigation.navigateToUnitGroups @@ -100,6 +101,9 @@ internal fun UnittoNavigation( navigateToSettings = ::navigateToSettings ) - // epochScreen(navigateToMenu = openDrawer) + dateDifferenceScreen( + navigateToMenu = openDrawer, + navigateToSettings = ::navigateToSettings + ) } } diff --git a/core/base/src/main/java/com/sadellie/unitto/core/base/TopLevelDestinations.kt b/core/base/src/main/java/com/sadellie/unitto/core/base/TopLevelDestinations.kt index fe6b3bb8..0982fb65 100644 --- a/core/base/src/main/java/com/sadellie/unitto/core/base/TopLevelDestinations.kt +++ b/core/base/src/main/java/com/sadellie/unitto/core/base/TopLevelDestinations.kt @@ -34,10 +34,10 @@ sealed class TopLevelDestinations( name = R.string.calculator ) -// object Epoch : TopLevelDestinations( -// route = "epoch_route", -// name = R.string.epoch_converter -// ) + object DateDifference : TopLevelDestinations( + route = "date_difference_route", + name = R.string.date_difference + ) object Settings : TopLevelDestinations( route = "settings_graph", diff --git a/core/base/src/main/res/values/strings.xml b/core/base/src/main/res/values/strings.xml index ed74eb43..0fcd661b 100644 --- a/core/base/src/main/res/values/strings.xml +++ b/core/base/src/main/res/values/strings.xml @@ -1272,6 +1272,17 @@ No history Can\'t divide by 0 + Date difference + Select time + Start + End + Difference + Months + Days + Hours + Minute + Next + 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/DateTimePickerDialog.kt b/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/DateTimePickerDialog.kt new file mode 100644 index 00000000..5f038a52 --- /dev/null +++ b/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/DateTimePickerDialog.kt @@ -0,0 +1,280 @@ +/* + * 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.core.ui.common + +import android.text.format.DateFormat +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredWidth +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.DatePicker +import androidx.compose.material3.DatePickerDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TimePicker +import androidx.compose.material3.rememberDatePickerState +import androidx.compose.material3.rememberTimePickerState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.layout.Placeable +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.DialogProperties +import com.sadellie.unitto.core.base.R +import java.time.Instant +import java.time.LocalDateTime +import java.time.ZoneId +import java.time.ZoneOffset +import kotlin.math.max + +@Composable +fun TimePickerDialog( + modifier: Modifier = Modifier, + localDateTime: LocalDateTime, + confirmLabel: String = stringResource(R.string.ok_label), + dismissLabel: String = stringResource(R.string.cancel_label), + onDismiss: () -> Unit = {}, + onConfirm: (LocalDateTime) -> Unit, +) { + val pickerState = rememberTimePickerState( + localDateTime.hour, + localDateTime.minute, + DateFormat.is24HourFormat(LocalContext.current) + ) + + AlertDialog( + onDismissRequest = onDismiss, + modifier = modifier.wrapContentHeight(), + properties = DialogProperties() + ) { + Surface( + modifier = modifier, + shape = MaterialTheme.shapes.extraLarge, + color = MaterialTheme.colorScheme.surface, + tonalElevation = 6.dp, + ) { + Column( + modifier = Modifier.padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = stringResource(R.string.select_time), + style = MaterialTheme.typography.labelMedium, + modifier = Modifier.align(Alignment.Start) + ) + + TimePicker( + state = pickerState, + modifier = Modifier.padding(top = 20.dp) + ) + + Row( + modifier = Modifier.align(Alignment.End), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + TextButton( + onClick = onDismiss + ) { + Text(text = dismissLabel) + } + TextButton( + onClick = { + onConfirm( + localDateTime + .withHour(pickerState.hour) + .withMinute(pickerState.minute) + ) + } + ) { + Text(text = confirmLabel) + } + } + } + } + } +} + +@Composable +fun DatePickerDialog( + modifier: Modifier = Modifier, + localDateTime: LocalDateTime, + confirmLabel: String = stringResource(R.string.ok_label), + dismissLabel: String = stringResource(R.string.cancel_label), + onDismiss: () -> Unit = {}, + onConfirm: (LocalDateTime) -> Unit, +) { + val pickerState = rememberDatePickerState(localDateTime.toEpochSecond(ZoneOffset.UTC) * 1000) + + AlertDialog( + onDismissRequest = onDismiss, + modifier = modifier.wrapContentHeight(), + properties = DialogProperties(usePlatformDefaultWidth = false) + ) { + Surface( + modifier = modifier + .requiredWidth(360.dp) + .heightIn(max = 568.dp), + shape = DatePickerDefaults.shape, + color = MaterialTheme.colorScheme.surface, + tonalElevation = 6.dp, + ) { + Column(verticalArrangement = Arrangement.SpaceBetween) { + DatePicker(state = pickerState) + + Box(modifier = Modifier + .align(Alignment.End) + .padding(DialogButtonsPadding)) { + AlertDialogFlowRow( + mainAxisSpacing = DialogButtonsMainAxisSpacing, + crossAxisSpacing = DialogButtonsCrossAxisSpacing + ) { + TextButton( + onClick = onDismiss + ) { + Text(text = dismissLabel) + } + TextButton( + onClick = { + val millis = pickerState.selectedDateMillis ?: return@TextButton + + val date = LocalDateTime.ofInstant( + Instant.ofEpochMilli(millis), ZoneId.systemDefault() + ) + + onConfirm( + localDateTime + .withYear(date.year) + .withMonth(date.monthValue) + .withDayOfMonth(date.dayOfMonth) + ) + } + ) { + Text(text = confirmLabel) + } + } + } + } + } + } +} + +// From androidx/compose/material3/AlertDialog.kt +@Composable +private fun AlertDialogFlowRow( + mainAxisSpacing: Dp, + crossAxisSpacing: Dp, + content: @Composable () -> Unit +) { + Layout(content) { measurables, constraints -> + val sequences = mutableListOf>() + val crossAxisSizes = mutableListOf() + val crossAxisPositions = mutableListOf() + + var mainAxisSpace = 0 + var crossAxisSpace = 0 + + val currentSequence = mutableListOf() + var currentMainAxisSize = 0 + var currentCrossAxisSize = 0 + + // Return whether the placeable can be added to the current sequence. + fun canAddToCurrentSequence(placeable: Placeable) = + currentSequence.isEmpty() || currentMainAxisSize + mainAxisSpacing.roundToPx() + + placeable.width <= constraints.maxWidth + + // Store current sequence information and start a new sequence. + fun startNewSequence() { + if (sequences.isNotEmpty()) { + crossAxisSpace += crossAxisSpacing.roundToPx() + } + sequences += currentSequence.toList() + crossAxisSizes += currentCrossAxisSize + crossAxisPositions += crossAxisSpace + + crossAxisSpace += currentCrossAxisSize + mainAxisSpace = max(mainAxisSpace, currentMainAxisSize) + + currentSequence.clear() + currentMainAxisSize = 0 + currentCrossAxisSize = 0 + } + + for (measurable in measurables) { + // Ask the child for its preferred size. + val placeable = measurable.measure(constraints) + + // Start a new sequence if there is not enough space. + if (!canAddToCurrentSequence(placeable)) startNewSequence() + + // Add the child to the current sequence. + if (currentSequence.isNotEmpty()) { + currentMainAxisSize += mainAxisSpacing.roundToPx() + } + currentSequence.add(placeable) + currentMainAxisSize += placeable.width + currentCrossAxisSize = max(currentCrossAxisSize, placeable.height) + } + + if (currentSequence.isNotEmpty()) startNewSequence() + + val mainAxisLayoutSize = max(mainAxisSpace, constraints.minWidth) + + val crossAxisLayoutSize = max(crossAxisSpace, constraints.minHeight) + + layout(mainAxisLayoutSize, crossAxisLayoutSize) { + sequences.forEachIndexed { i, placeables -> + val childrenMainAxisSizes = IntArray(placeables.size) { j -> + placeables[j].width + + if (j < placeables.lastIndex) mainAxisSpacing.roundToPx() else 0 + } + val arrangement = Arrangement.End + val mainAxisPositions = IntArray(childrenMainAxisSizes.size) { 0 } + with(arrangement) { + arrange( + mainAxisLayoutSize, childrenMainAxisSizes, + layoutDirection, mainAxisPositions + ) + } + placeables.forEachIndexed { j, placeable -> + placeable.place( + x = mainAxisPositions[j], + y = crossAxisPositions[i] + ) + } + } + } + } +} + +private val DialogButtonsPadding by lazy { PaddingValues(bottom = 8.dp, end = 6.dp) } +private val DialogButtonsMainAxisSpacing by lazy { 8.dp } +private val DialogButtonsCrossAxisSpacing by lazy { 12.dp } diff --git a/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/UnittoButton.kt b/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/UnittoButton.kt index 76276f2f..8416a49e 100644 --- a/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/UnittoButton.kt +++ b/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/UnittoButton.kt @@ -19,7 +19,6 @@ package com.sadellie.unitto.core.ui.common import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.PaddingValues @@ -27,8 +26,6 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.ripple.rememberRipple import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme @@ -40,17 +37,13 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.Shape -import androidx.compose.ui.semantics.Role @Composable fun UnittoButton( modifier: Modifier = Modifier, onClick: () -> Unit, onLongClick: (() -> Unit)? = null, - shape: Shape = RoundedCornerShape(100), containerColor: Color, contentColor: Color = contentColorFor(containerColor), border: BorderStroke? = null, @@ -59,12 +52,11 @@ fun UnittoButton( content: @Composable RowScope.() -> Unit ) { Surface( - modifier = modifier.clip(shape).combinedClickable( + modifier = modifier.squashable( onClick = onClick, onLongClick = onLongClick, interactionSource = interactionSource, - indication = rememberRipple(), - role = Role.Button, + cornerRadiusRange = 30..50 ), color = containerColor, contentColor = contentColor, diff --git a/core/ui/src/main/java/com/sadellie/unitto/core/ui/model/DrawerItems.kt b/core/ui/src/main/java/com/sadellie/unitto/core/ui/model/DrawerItems.kt index f3005c52..d3169397 100644 --- a/core/ui/src/main/java/com/sadellie/unitto/core/ui/model/DrawerItems.kt +++ b/core/ui/src/main/java/com/sadellie/unitto/core/ui/model/DrawerItems.kt @@ -20,9 +20,11 @@ package com.sadellie.unitto.core.ui.model import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Calculate +import androidx.compose.material.icons.filled.Event import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.SwapHoriz import androidx.compose.material.icons.outlined.Calculate +import androidx.compose.material.icons.outlined.Event import androidx.compose.material.icons.outlined.Settings import androidx.compose.material.icons.outlined.SwapHoriz import androidx.compose.ui.graphics.vector.ImageVector @@ -45,6 +47,12 @@ sealed class DrawerItems( defaultIcon = Icons.Outlined.SwapHoriz ) + object DateDifference : DrawerItems( + destination = TopLevelDestinations.DateDifference, + selectedIcon = Icons.Filled.Event, + defaultIcon = Icons.Outlined.Event + ) + object Settings : DrawerItems( destination = TopLevelDestinations.Settings, selectedIcon = Icons.Filled.Settings, diff --git a/feature/datedifference/.gitignore b/feature/datedifference/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/feature/datedifference/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature/datedifference/build.gradle.kts b/feature/datedifference/build.gradle.kts new file mode 100644 index 00000000..297c0df5 --- /dev/null +++ b/feature/datedifference/build.gradle.kts @@ -0,0 +1,37 @@ +/* + * 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.datedifference" +} + +dependencies { + testImplementation(libs.junit) + implementation(libs.com.github.sadellie.themmo) + + implementation(project(mapOf("path" to ":data:common"))) + implementation(project(mapOf("path" to ":data:userprefs"))) + implementation(project(mapOf("path" to ":data:model"))) +} diff --git a/feature/datedifference/consumer-rules.pro b/feature/datedifference/consumer-rules.pro new file mode 100644 index 00000000..e69de29b diff --git a/feature/datedifference/src/main/AndroidManifest.xml b/feature/datedifference/src/main/AndroidManifest.xml new file mode 100644 index 00000000..7bdbce91 --- /dev/null +++ b/feature/datedifference/src/main/AndroidManifest.xml @@ -0,0 +1,22 @@ + + + + + + \ No newline at end of file diff --git a/feature/datedifference/src/main/java/com/sadellie/unitto/feature/datedifference/DateDifference.kt b/feature/datedifference/src/main/java/com/sadellie/unitto/feature/datedifference/DateDifference.kt new file mode 100644 index 00000000..3351825a --- /dev/null +++ b/feature/datedifference/src/main/java/com/sadellie/unitto/feature/datedifference/DateDifference.kt @@ -0,0 +1,64 @@ +/* + * 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.datedifference + +import java.time.Duration +import java.time.LocalDateTime + +internal sealed class DateDifference( + open val months: Long = 0, + open val days: Long = 0, + open val hours: Long = 0, + open val minutes: Long = 0, +) { + data class Default( + override val months: Long = 0, + override val days: Long = 0, + override val hours: Long = 0, + override val minutes: Long = 0, + ) : DateDifference( + months = months, + days = days, + hours = hours, + minutes = minutes, + ) + + object Zero : DateDifference() +} + +internal infix operator fun LocalDateTime.minus(localDateTime: LocalDateTime): DateDifference { + val duration = Duration.between(this, localDateTime).abs() + + if (duration.isZero) return DateDifference.Zero + + val durationDays = duration.toDays() + val months = durationDays / 30 + val days = durationDays % 30 + val hours = duration.toHoursPart().toLong() + val minutes = duration.toMinutesPart().toLong() + + if (listOf(months, days, hours, minutes).all { it == 0L }) return DateDifference.Zero + + return DateDifference.Default( + months = months, + days = days, + hours = hours, + minutes = minutes + ) +} diff --git a/feature/datedifference/src/main/java/com/sadellie/unitto/feature/datedifference/DateDifferenceScreen.kt b/feature/datedifference/src/main/java/com/sadellie/unitto/feature/datedifference/DateDifferenceScreen.kt new file mode 100644 index 00000000..038ab976 --- /dev/null +++ b/feature/datedifference/src/main/java/com/sadellie/unitto/feature/datedifference/DateDifferenceScreen.kt @@ -0,0 +1,232 @@ +/* + * 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.datedifference + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Settings +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +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.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.sadellie.unitto.core.base.R +import com.sadellie.unitto.core.ui.common.DatePickerDialog +import com.sadellie.unitto.core.ui.common.MenuButton +import com.sadellie.unitto.core.ui.common.TimePickerDialog +import com.sadellie.unitto.core.ui.common.UnittoScreenWithTopBar +import com.sadellie.unitto.feature.datedifference.components.DateTimeResultBlock +import com.sadellie.unitto.feature.datedifference.components.DateTimeSelectorBlock +import java.time.LocalDateTime + + +@Composable +internal fun DateDifferenceRoute( + viewModel: DateDifferenceViewModel = hiltViewModel(), + navigateToMenu: () -> Unit, + navigateToSettings: () -> Unit, +) { + val uiState = viewModel.uiState.collectAsStateWithLifecycle() + DateDifferenceScreen( + navigateToMenu = navigateToMenu, + navigateToSettings = navigateToSettings, + uiState = uiState.value, + updateStart = viewModel::setStartTime, + updateEnd = viewModel::setEndTime + ) +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +internal fun DateDifferenceScreen( + navigateToMenu: () -> Unit, + navigateToSettings: () -> Unit, + updateStart: (LocalDateTime) -> Unit, + updateEnd: (LocalDateTime) -> Unit, + uiState: UIState +) { + var dialogState by remember { mutableStateOf(DialogState.NONE) } + + UnittoScreenWithTopBar( + title = { Text(stringResource(R.string.date_difference)) }, + navigationIcon = { MenuButton(navigateToMenu) }, + actions = { + IconButton(onClick = navigateToSettings) { + Icon( + Icons.Outlined.Settings, + contentDescription = stringResource(R.string.open_settings_description) + ) + } + } + ) { paddingValues -> + FlowRow( + modifier = Modifier + .padding(paddingValues) + .padding(horizontal = 16.dp), + maxItemsInEachRow = 2, + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + DateTimeSelectorBlock( + modifier = Modifier + .weight(1f) + .fillMaxWidth(), + title = stringResource(R.string.date_difference_start), + onClick = { dialogState = DialogState.FROM }, + onTimeClick = { dialogState = DialogState.FROM_TIME }, + onDateClick = { dialogState = DialogState.FROM_DATE }, + dateTime = uiState.start + ) + + DateTimeSelectorBlock( + modifier = Modifier + .weight(1f) + .fillMaxWidth(), + title = stringResource(R.string.date_difference_end), + onClick = { dialogState = DialogState.TO }, + onTimeClick = { dialogState = DialogState.TO_TIME }, + onDateClick = { dialogState = DialogState.TO_DATE }, + dateTime = uiState.end + ) + + AnimatedVisibility( + visible = uiState.result is DateDifference.Default, + enter = expandVertically(), + exit = shrinkVertically() + ) { + DateTimeResultBlock( + modifier = Modifier + .weight(2f) + .fillMaxWidth(), + dateDifference = uiState.result + ) + } + } + } + + fun resetDialog() { + dialogState = DialogState.NONE + } + + when (dialogState) { + DialogState.FROM -> { + TimePickerDialog( + localDateTime = uiState.start, + onDismiss = ::resetDialog, + onConfirm = { + updateStart(it) + dialogState = DialogState.FROM_DATE + }, + confirmLabel = stringResource(R.string.next_label) + ) + } + + DialogState.FROM_TIME -> { + TimePickerDialog( + localDateTime = uiState.start, + onDismiss = ::resetDialog, + onConfirm = { + updateStart(it) + resetDialog() + } + ) + } + + DialogState.FROM_DATE -> { + DatePickerDialog( + localDateTime = uiState.start, + onDismiss = ::resetDialog, + onConfirm = { + updateStart(it) + resetDialog() + } + ) + } + + DialogState.TO -> { + TimePickerDialog( + localDateTime = uiState.end, + onDismiss = ::resetDialog, + onConfirm = { + updateEnd(it) + dialogState = DialogState.TO_DATE + }, + confirmLabel = stringResource(R.string.next_label) + ) + } + + DialogState.TO_TIME -> { + TimePickerDialog( + localDateTime = uiState.end, + onDismiss = ::resetDialog, + onConfirm = { + updateEnd(it) + resetDialog() + } + ) + } + + DialogState.TO_DATE -> { + DatePickerDialog( + localDateTime = uiState.end, + onDismiss = ::resetDialog, + onConfirm = { + updateEnd(it) + resetDialog() + } + ) + } + + else -> {} + } +} + +private enum class DialogState { + NONE, FROM, FROM_TIME, FROM_DATE, TO, TO_TIME, TO_DATE +} + +@Preview +@Composable +private fun DateDifferenceScreenPreview() { + DateDifferenceScreen( + navigateToMenu = {}, + navigateToSettings = {}, + updateStart = {}, + updateEnd = {}, + uiState = UIState( + result = DateDifference.Default(1, 2, 3, 4) + ) + ) +} diff --git a/feature/datedifference/src/main/java/com/sadellie/unitto/feature/datedifference/DateDifferenceViewModel.kt b/feature/datedifference/src/main/java/com/sadellie/unitto/feature/datedifference/DateDifferenceViewModel.kt new file mode 100644 index 00000000..1d151afe --- /dev/null +++ b/feature/datedifference/src/main/java/com/sadellie/unitto/feature/datedifference/DateDifferenceViewModel.kt @@ -0,0 +1,61 @@ +/* + * 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.datedifference + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +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 java.time.LocalDateTime +import javax.inject.Inject + +@HiltViewModel +internal class DateDifferenceViewModel @Inject constructor() : ViewModel() { + private val _start = MutableStateFlow(LocalDateTime.now()) + private val _end = MutableStateFlow(LocalDateTime.now()) + private val _result = MutableStateFlow(DateDifference.Zero) + + val uiState = combine(_start, _end, _result) { start, end, result -> + return@combine UIState(start, end, result) + } + .stateIn( + viewModelScope, SharingStarted.WhileSubscribed(5000L), UIState() + ) + + fun setStartTime(newTime: LocalDateTime) = _start.update { newTime } + + fun setEndTime(newTime: LocalDateTime) = _end.update { newTime } + + init { + viewModelScope.launch(Dispatchers.Default) { + merge(_start, _end).collectLatest { + val difference = _start.value - _end.value + _result.update { difference } + } + } + } +} diff --git a/feature/datedifference/src/main/java/com/sadellie/unitto/feature/datedifference/UIState.kt b/feature/datedifference/src/main/java/com/sadellie/unitto/feature/datedifference/UIState.kt new file mode 100644 index 00000000..7dd57728 --- /dev/null +++ b/feature/datedifference/src/main/java/com/sadellie/unitto/feature/datedifference/UIState.kt @@ -0,0 +1,27 @@ +/* + * 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.datedifference + +import java.time.LocalDateTime + +internal data class UIState( + val start: LocalDateTime = LocalDateTime.now(), + val end: LocalDateTime = LocalDateTime.now(), + val result: DateDifference = DateDifference.Zero +) diff --git a/feature/datedifference/src/main/java/com/sadellie/unitto/feature/datedifference/components/DateTimeResultBlock.kt b/feature/datedifference/src/main/java/com/sadellie/unitto/feature/datedifference/components/DateTimeResultBlock.kt new file mode 100644 index 00000000..29001a14 --- /dev/null +++ b/feature/datedifference/src/main/java/com/sadellie/unitto/feature/datedifference/components/DateTimeResultBlock.kt @@ -0,0 +1,130 @@ +/* + * 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.datedifference.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.sadellie.unitto.core.base.R +import com.sadellie.unitto.core.ui.common.squashable +import com.sadellie.unitto.feature.datedifference.DateDifference + +@Composable +internal fun DateTimeResultBlock( + modifier: Modifier = Modifier, + dateDifference: DateDifference +) { + val clipboardManager = LocalClipboardManager.current + + val months = if (dateDifference.months > 0) { + "${stringResource(R.string.date_difference_months)}: ${dateDifference.months}" + } else "" + + val days = if (dateDifference.days > 0) { + "${stringResource(R.string.date_difference_days)}: ${dateDifference.days}" + } else "" + + val hours = if (dateDifference.hours > 0) { + "${stringResource(R.string.date_difference_hours)}: ${dateDifference.hours}" + } else "" + + val minutes = if (dateDifference.minutes > 0) { + "${stringResource(R.string.date_difference_minutes)}: ${dateDifference.minutes}" + } else "" + + val texts = listOf(months, days, hours, minutes) + + Column( + modifier = modifier + .squashable( + onClick = {}, + interactionSource = remember { MutableInteractionSource() }, + cornerRadiusRange = 8.dp..32.dp, + ) + .background(MaterialTheme.colorScheme.tertiaryContainer) + .padding(16.dp), + horizontalAlignment = Alignment.Start + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Bottom + ) { + Text( + stringResource(R.string.date_difference_result), + style = MaterialTheme.typography.labelMedium + ) + IconButton( + onClick = { + clipboardManager.setText( + AnnotatedString(texts.filter { it.isNotEmpty() }.joinToString(" ")) + ) + } + ) { + Icon(Icons.Default.ContentCopy, null) + } + } + + texts.forEach { + AnimatedVisibility( + visible = it.isNotEmpty(), + enter = expandVertically(), + exit = shrinkVertically() + ) { + Text(it, style = MaterialTheme.typography.displayMedium) + } + } + } +} + +@Preview +@Composable +private fun PreviewCard() { + DateTimeResultBlock( + modifier = Modifier, + dateDifference = DateDifference.Default( + months = 1, + days = 2, + hours = 3, + minutes = 4 + ) + ) +} diff --git a/feature/datedifference/src/main/java/com/sadellie/unitto/feature/datedifference/components/DateTimeSelectorBlock.kt b/feature/datedifference/src/main/java/com/sadellie/unitto/feature/datedifference/components/DateTimeSelectorBlock.kt new file mode 100644 index 00000000..8513178e --- /dev/null +++ b/feature/datedifference/src/main/java/com/sadellie/unitto/feature/datedifference/components/DateTimeSelectorBlock.kt @@ -0,0 +1,109 @@ +/* + * 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.datedifference.components + +import android.text.format.DateFormat +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material.ripple.rememberRipple +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import com.sadellie.unitto.core.ui.common.squashable +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter + +@Composable +internal fun DateTimeSelectorBlock( + modifier: Modifier, + title: String, + dateTime: LocalDateTime, + onClick: () -> Unit, + onTimeClick: () -> Unit, + onDateClick: () -> Unit, +) { + Column( + modifier = modifier + .squashable( + onClick = onClick, + interactionSource = remember { MutableInteractionSource() }, + cornerRadiusRange = 8.dp..32.dp + ) + .background(MaterialTheme.colorScheme.secondaryContainer) + .padding(16.dp), + horizontalAlignment = Alignment.Start + ) { + Text(title, style = MaterialTheme.typography.labelMedium) + + if (DateFormat.is24HourFormat(LocalContext.current)) { + Text( + modifier = Modifier.clickable( + indication = rememberRipple(), + interactionSource = remember { MutableInteractionSource() }, + onClick = onTimeClick + ), + text = dateTime.format(time24Formatter), + style = MaterialTheme.typography.displayMedium, + maxLines = 1 + ) + } else { + Column( + modifier = Modifier.clickable( + indication = rememberRipple(), + interactionSource = remember { MutableInteractionSource() }, + onClick = onTimeClick + ) + ) { + Text( + text = dateTime.format(time12Formatter), + style = MaterialTheme.typography.displayMedium, + maxLines = 1 + ) + Text( + text = dateTime.format(mTimeFormatter), + style = MaterialTheme.typography.bodyLarge, + maxLines = 1 + ) + } + } + + Text( + modifier = Modifier.clickable( + indication = rememberRipple(), + interactionSource = remember { MutableInteractionSource() }, + onClick = onDateClick + ), + text = dateTime.format(dateFormatter), + style = MaterialTheme.typography.bodySmall + ) + } +} + +private val time24Formatter by lazy { DateTimeFormatter.ofPattern("HH:mm") } +private val time12Formatter by lazy { DateTimeFormatter.ofPattern("hh:mm") } +private val dateFormatter by lazy { DateTimeFormatter.ofPattern("EEE, MMM d, y") } +private val mTimeFormatter by lazy { DateTimeFormatter.ofPattern("a") } diff --git a/feature/datedifference/src/main/java/com/sadellie/unitto/feature/datedifference/navigation/DateDifferenceNavigation.kt b/feature/datedifference/src/main/java/com/sadellie/unitto/feature/datedifference/navigation/DateDifferenceNavigation.kt new file mode 100644 index 00000000..d779d938 --- /dev/null +++ b/feature/datedifference/src/main/java/com/sadellie/unitto/feature/datedifference/navigation/DateDifferenceNavigation.kt @@ -0,0 +1,44 @@ +/* + * 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.datedifference.navigation + +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import androidx.navigation.navDeepLink +import com.sadellie.unitto.core.base.TopLevelDestinations +import com.sadellie.unitto.feature.datedifference.DateDifferenceRoute + +private val dateDifferenceRoute: String by lazy { TopLevelDestinations.DateDifference.route } + +fun NavGraphBuilder.dateDifferenceScreen( + navigateToMenu: () -> Unit, + navigateToSettings: () -> Unit +) { + composable( + route = dateDifferenceRoute, + deepLinks = listOf( + navDeepLink { uriPattern = "app://com.sadellie.unitto/$dateDifferenceRoute" } + ) + ) { + DateDifferenceRoute( + navigateToMenu = navigateToMenu, + navigateToSettings = navigateToSettings + ) + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 4f3cc9f0..69bc9f2f 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -24,8 +24,8 @@ include(":core:ui") include(":feature:converter") include(":feature:unitslist") include(":feature:calculator") +include(":feature:datedifference") include(":feature:settings") -// include(":feature:epoch") include(":data:userprefs") include(":data:unitgroups") include(":data:licenses") From 1b61b4bd851c4d35f678cc9ae3a45880030aafc5 Mon Sep 17 00:00:00 2001 From: Sad Ellie Date: Thu, 18 May 2023 23:33:10 +0300 Subject: [PATCH 13/51] Added SettingsButton.kt --- .../unitto/core/ui/common/SettingsButton.kt | 34 +++++++++++++++++++ .../feature/calculator/CalculatorScreen.kt | 9 ++--- .../feature/converter/ConverterScreen.kt | 12 ++----- .../datedifference/DateDifferenceScreen.kt | 12 ++----- 4 files changed, 40 insertions(+), 27 deletions(-) create mode 100644 core/ui/src/main/java/com/sadellie/unitto/core/ui/common/SettingsButton.kt diff --git a/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/SettingsButton.kt b/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/SettingsButton.kt new file mode 100644 index 00000000..12253eae --- /dev/null +++ b/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/SettingsButton.kt @@ -0,0 +1,34 @@ +/* + * 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.core.ui.common + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Settings +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import com.sadellie.unitto.core.base.R + +@Composable +fun SettingsButton(onClick: () -> Unit) { + IconButton(onClick) { + Icon(Icons.Outlined.Settings, stringResource(R.string.open_settings_description)) + } +} 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 1523c83f..bf7fbf4d 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 @@ -37,7 +37,6 @@ import androidx.compose.foundation.layout.sizeIn import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Delete -import androidx.compose.material.icons.outlined.Settings import androidx.compose.material3.AlertDialog import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -66,6 +65,7 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.sadellie.unitto.core.base.R import com.sadellie.unitto.core.ui.common.MenuButton +import com.sadellie.unitto.core.ui.common.SettingsButton import com.sadellie.unitto.core.ui.common.UnittoScreenWithTopBar import com.sadellie.unitto.core.ui.common.textfield.ExpressionTextField import com.sadellie.unitto.core.ui.common.textfield.UnformattedTextField @@ -143,12 +143,7 @@ private fun CalculatorScreen( } ) } else { - IconButton(onClick = navigateToSettings) { - Icon( - Icons.Outlined.Settings, - contentDescription = stringResource(R.string.open_settings_description) - ) - } + SettingsButton(navigateToSettings) } } } diff --git a/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/ConverterScreen.kt b/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/ConverterScreen.kt index 970baf4f..be321efc 100644 --- a/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/ConverterScreen.kt +++ b/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/ConverterScreen.kt @@ -20,10 +20,6 @@ package com.sadellie.unitto.feature.converter import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.Settings -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable @@ -38,6 +34,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.sadellie.unitto.core.base.R import com.sadellie.unitto.core.ui.common.MenuButton import com.sadellie.unitto.core.ui.common.PortraitLandscape +import com.sadellie.unitto.core.ui.common.SettingsButton import com.sadellie.unitto.core.ui.common.UnittoScreenWithTopBar import com.sadellie.unitto.feature.converter.components.Keyboard import com.sadellie.unitto.feature.converter.components.TopScreenPart @@ -85,12 +82,7 @@ private fun ConverterScreen( title = { Text(stringResource(R.string.unit_converter)) }, navigationIcon = { MenuButton { navigateToMenu() } }, actions = { - IconButton(onClick = navigateToSettings) { - Icon( - Icons.Outlined.Settings, - contentDescription = stringResource(R.string.open_settings_description) - ) - } + SettingsButton(navigateToSettings) }, colors = TopAppBarDefaults .centerAlignedTopAppBarColors(containerColor = Color.Transparent), diff --git a/feature/datedifference/src/main/java/com/sadellie/unitto/feature/datedifference/DateDifferenceScreen.kt b/feature/datedifference/src/main/java/com/sadellie/unitto/feature/datedifference/DateDifferenceScreen.kt index 038ab976..69f19b26 100644 --- a/feature/datedifference/src/main/java/com/sadellie/unitto/feature/datedifference/DateDifferenceScreen.kt +++ b/feature/datedifference/src/main/java/com/sadellie/unitto/feature/datedifference/DateDifferenceScreen.kt @@ -26,10 +26,6 @@ import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.Settings -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -45,6 +41,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.sadellie.unitto.core.base.R import com.sadellie.unitto.core.ui.common.DatePickerDialog import com.sadellie.unitto.core.ui.common.MenuButton +import com.sadellie.unitto.core.ui.common.SettingsButton import com.sadellie.unitto.core.ui.common.TimePickerDialog import com.sadellie.unitto.core.ui.common.UnittoScreenWithTopBar import com.sadellie.unitto.feature.datedifference.components.DateTimeResultBlock @@ -83,12 +80,7 @@ internal fun DateDifferenceScreen( title = { Text(stringResource(R.string.date_difference)) }, navigationIcon = { MenuButton(navigateToMenu) }, actions = { - IconButton(onClick = navigateToSettings) { - Icon( - Icons.Outlined.Settings, - contentDescription = stringResource(R.string.open_settings_description) - ) - } + SettingsButton(navigateToSettings) } ) { paddingValues -> FlowRow( From 86dce329ac4c7653ba8aea4c1b1bd8811576038e Mon Sep 17 00:00:00 2001 From: Sad Ellie Date: Thu, 18 May 2023 23:43:08 +0300 Subject: [PATCH 14/51] Separate User Preferences --- app/build.gradle.kts | 3 +- .../java/com/sadellie/unitto/MainActivity.kt | 6 +- .../java/com/sadellie/unitto/UnittoApp.kt | 16 +-- .../com/sadellie/unitto/UnittoNavigation.kt | 3 - .../data/unitgroups/UnitGroupsRepository.kt | 115 --------------- .../unitto/data/userprefs/UserPreferences.kt | 83 +++++++++-- feature/calculator/build.gradle.kts | 1 - .../feature/calculator/CalculatorViewModel.kt | 8 +- feature/converter/build.gradle.kts | 1 - .../feature/converter/ConverterViewModel.kt | 8 +- .../unitto/feature/settings/AboutScreen.kt | 3 +- .../unitto/feature/settings/SettingsScreen.kt | 3 +- .../feature/settings/SettingsViewModel.kt | 117 +-------------- .../settings/navigation/SettingsNavigation.kt | 23 ++- .../settings/{ => themes}/ThemesScreen.kt | 5 +- .../settings/themes/ThemesViewModel.kt | 80 +++++++++++ .../{ => unitgroups}/UnitGroupsScreen.kt | 28 ++-- .../settings/unitgroups/UnitGroupsUIState.kt | 26 ++++ .../unitgroups/UnitGroupsViewModel.kt | 135 ++++++++++++++++++ .../feature/unitslist/UnitsListViewModel.kt | 18 +-- 20 files changed, 373 insertions(+), 309 deletions(-) delete mode 100644 data/unitgroups/src/main/java/com/sadellie/unitto/data/unitgroups/UnitGroupsRepository.kt rename feature/settings/src/main/java/com/sadellie/unitto/feature/settings/{ => themes}/ThemesScreen.kt (98%) create mode 100644 feature/settings/src/main/java/com/sadellie/unitto/feature/settings/themes/ThemesViewModel.kt rename feature/settings/src/main/java/com/sadellie/unitto/feature/settings/{ => unitgroups}/UnitGroupsScreen.kt (89%) create mode 100644 feature/settings/src/main/java/com/sadellie/unitto/feature/settings/unitgroups/UnitGroupsUIState.kt create mode 100644 feature/settings/src/main/java/com/sadellie/unitto/feature/settings/unitgroups/UnitGroupsViewModel.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 89c279c8..24891849 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -120,8 +120,7 @@ dependencies { implementation(project(mapOf("path" to ":feature:calculator"))) implementation(project(mapOf("path" to ":feature:settings"))) implementation(project(mapOf("path" to ":feature:unitslist"))) - implementation(project(mapOf("path" to ":feature:datedifference"))) - implementation(project(mapOf("path" to ":data:units"))) + implementation(project(mapOf("path" to ":feature:datedifference"))) implementation(project(mapOf("path" to ":data:model"))) implementation(project(mapOf("path" to ":data:userprefs"))) implementation(project(mapOf("path" to ":core:ui"))) diff --git a/app/src/main/java/com/sadellie/unitto/MainActivity.kt b/app/src/main/java/com/sadellie/unitto/MainActivity.kt index 490a109c..b68879ed 100644 --- a/app/src/main/java/com/sadellie/unitto/MainActivity.kt +++ b/app/src/main/java/com/sadellie/unitto/MainActivity.kt @@ -40,13 +40,11 @@ internal class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - val userPrefsFlow = userPrefsRepository.userPreferencesFlow - setContent { - val userPrefs = userPrefsFlow + val uiPrefs = userPrefsRepository.uiPreferencesFlow .collectAsStateWithLifecycle(null).value - if (userPrefs != null) UnittoApp(userPrefs) + if (uiPrefs != null) UnittoApp(uiPrefs) } } diff --git a/app/src/main/java/com/sadellie/unitto/UnittoApp.kt b/app/src/main/java/com/sadellie/unitto/UnittoApp.kt index c06d4c74..59ce7f13 100644 --- a/app/src/main/java/com/sadellie/unitto/UnittoApp.kt +++ b/app/src/main/java/com/sadellie/unitto/UnittoApp.kt @@ -44,23 +44,23 @@ import com.sadellie.unitto.core.ui.model.DrawerItems import com.sadellie.unitto.core.ui.theme.AppTypography import com.sadellie.unitto.core.ui.theme.DarkThemeColors import com.sadellie.unitto.core.ui.theme.LightThemeColors -import com.sadellie.unitto.data.userprefs.UserPreferences +import com.sadellie.unitto.data.userprefs.UIPreferences import io.github.sadellie.themmo.Themmo import io.github.sadellie.themmo.rememberThemmoController import kotlinx.coroutines.launch @Composable -internal fun UnittoApp(userPrefs: UserPreferences) { +internal fun UnittoApp(uiPrefs: UIPreferences) { val themmoController = rememberThemmoController( lightColorScheme = LightThemeColors, darkColorScheme = DarkThemeColors, // Anything below will not be called if theming mode is still loading from DataStore - themingMode = userPrefs.themingMode, - dynamicThemeEnabled = userPrefs.enableDynamicTheme, - amoledThemeEnabled = userPrefs.enableAmoledTheme, - customColor = userPrefs.customColor, - monetMode = userPrefs.monetMode + themingMode = uiPrefs.themingMode, + dynamicThemeEnabled = uiPrefs.enableDynamicTheme, + amoledThemeEnabled = uiPrefs.enableAmoledTheme, + customColor = uiPrefs.customColor, + monetMode = uiPrefs.monetMode ) val navController = rememberNavController() val sysUiController = rememberSystemUiController() @@ -131,7 +131,7 @@ internal fun UnittoApp(userPrefs: UserPreferences) { UnittoNavigation( navController = navController, themmoController = it, - startDestination = userPrefs.startingScreen, + startDestination = uiPrefs.startingScreen, openDrawer = { drawerScope.launch { drawerState.open() } } ) } diff --git a/app/src/main/java/com/sadellie/unitto/UnittoNavigation.kt b/app/src/main/java/com/sadellie/unitto/UnittoNavigation.kt index c11bec89..8e5fcf8d 100644 --- a/app/src/main/java/com/sadellie/unitto/UnittoNavigation.kt +++ b/app/src/main/java/com/sadellie/unitto/UnittoNavigation.kt @@ -30,7 +30,6 @@ import com.sadellie.unitto.feature.calculator.navigation.calculatorScreen import com.sadellie.unitto.feature.converter.ConverterViewModel import com.sadellie.unitto.feature.converter.navigation.converterScreen import com.sadellie.unitto.feature.datedifference.navigation.dateDifferenceScreen -import com.sadellie.unitto.feature.settings.SettingsViewModel import com.sadellie.unitto.feature.settings.navigation.navigateToSettings import com.sadellie.unitto.feature.settings.navigation.navigateToUnitGroups import com.sadellie.unitto.feature.settings.navigation.settingGraph @@ -50,7 +49,6 @@ internal fun UnittoNavigation( ) { val converterViewModel: ConverterViewModel = hiltViewModel() val unitsListViewModel: UnitsListViewModel = hiltViewModel() - val settingsViewModel: SettingsViewModel = hiltViewModel() NavHost( navController = navController, @@ -90,7 +88,6 @@ internal fun UnittoNavigation( ) settingGraph( - settingsViewModel = settingsViewModel, themmoController = themmoController, navController = navController, menuButtonClick = openDrawer diff --git a/data/unitgroups/src/main/java/com/sadellie/unitto/data/unitgroups/UnitGroupsRepository.kt b/data/unitgroups/src/main/java/com/sadellie/unitto/data/unitgroups/UnitGroupsRepository.kt deleted file mode 100644 index 44fbe968..00000000 --- a/data/unitgroups/src/main/java/com/sadellie/unitto/data/unitgroups/UnitGroupsRepository.kt +++ /dev/null @@ -1,115 +0,0 @@ -/* - * Unitto is a unit converter for Android - * Copyright (c) 2022-2023 Elshan Agaev - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package com.sadellie.unitto.data.unitgroups - -import com.sadellie.unitto.data.model.ALL_UNIT_GROUPS -import com.sadellie.unitto.data.model.UnitGroup -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import org.burnoutcrew.reorderable.ItemPosition -import javax.inject.Inject -import javax.inject.Singleton - -/** - * Repository that holds information about shown and hidden [UnitGroup]s and provides methods to - * show/hide [UnitGroup]s. - */ -@Singleton -class UnitGroupsRepository @Inject constructor() { - - /** - * Mutex is need needed because we work with flow (sync stuff). - */ - private val mutex = Mutex() - - /** - * Currently shown [UnitGroup]s. - */ - var shownUnitGroups = MutableStateFlow(listOf()) - private set - - /** - * Currently hidden [UnitGroup]s. - */ - var hiddenUnitGroups = MutableStateFlow(listOf()) - private set - - /** - * Sets [shownUnitGroups] and updates [hiddenUnitGroups] as a side effect. [hiddenUnitGroups] is - * everything from [ALL_UNIT_GROUPS] that was not in [shownUnitGroups]. - * - * @param list List of [UnitGroup]s that need to be shown. - */ - suspend fun updateShownGroups(list: List) { - mutex.withLock { - shownUnitGroups.value = list - hiddenUnitGroups.value = ALL_UNIT_GROUPS - list.toSet() - } - } - - /** - * Moves [UnitGroup] from [shownUnitGroups] to [hiddenUnitGroups] - * - * @param unitGroup [UnitGroup] to hide. - */ - suspend fun markUnitGroupAsHidden(unitGroup: UnitGroup) { - mutex.withLock { - shownUnitGroups.value = shownUnitGroups.value - unitGroup - // Newly hidden unit will appear at the top of the list - hiddenUnitGroups.value = listOf(unitGroup) + hiddenUnitGroups.value - } - } - - /** - * Moves [UnitGroup] from [hiddenUnitGroups] to [shownUnitGroups] - * - * @param unitGroup [UnitGroup] to show. - */ - suspend fun markUnitGroupAsShown(unitGroup: UnitGroup) { - mutex.withLock { - hiddenUnitGroups.value = hiddenUnitGroups.value - unitGroup - shownUnitGroups.value = shownUnitGroups.value + unitGroup - } - } - - /** - * Moves [UnitGroup] in [shownUnitGroups] from one index to another (reorder). - * - * @param from Position from which we need to move from - * @param to Position where to put [UnitGroup] - */ - suspend fun moveShownUnitGroups(from: ItemPosition, to: ItemPosition) { - mutex.withLock { - shownUnitGroups.value = shownUnitGroups.value.toMutableList().apply { - val initialIndex = shownUnitGroups.value.indexOfFirst { it == from.key } - /** - * No such item. Happens when dragging item and clicking "remove" while item is - * still being dragged. - */ - if (initialIndex == -1) return - - add( - shownUnitGroups.value.indexOfFirst { it == to.key }, - removeAt(initialIndex) - ) - } - } - } -} diff --git a/data/userprefs/src/main/java/com/sadellie/unitto/data/userprefs/UserPreferences.kt b/data/userprefs/src/main/java/com/sadellie/unitto/data/userprefs/UserPreferences.kt index a2c46562..a137de91 100644 --- a/data/userprefs/src/main/java/com/sadellie/unitto/data/userprefs/UserPreferences.kt +++ b/data/userprefs/src/main/java/com/sadellie/unitto/data/userprefs/UserPreferences.kt @@ -39,6 +39,7 @@ import io.github.sadellie.themmo.MonetMode import io.github.sadellie.themmo.ThemingMode import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.map import java.io.IOException import javax.inject.Inject @@ -84,6 +85,29 @@ data class UserPreferences( val unitConverterSorting: UnitsListSorting = UnitsListSorting.USAGE, ) +data class UIPreferences( + val themingMode: ThemingMode = ThemingMode.AUTO, + val enableDynamicTheme: Boolean = false, + val enableAmoledTheme: Boolean = false, + val customColor: Color = Color.Unspecified, + val monetMode: MonetMode = MonetMode.TONAL_SPOT, + val startingScreen: String = TopLevelDestinations.Converter.route, +) + +data class MainPreferences( + val digitsPrecision: Int = 3, + val separator: Int = Separator.SPACES, + val outputFormat: Int = OutputFormat.PLAIN, + val latestLeftSideUnit: String = MyUnitIDS.kilometer, + val latestRightSideUnit: String = MyUnitIDS.mile, + val shownUnitGroups: List = ALL_UNIT_GROUPS, + val enableVibrations: Boolean = true, + val radianMode: Boolean = true, + val unitConverterFavoritesOnly: Boolean = false, + val unitConverterFormatTime: Boolean = false, + val unitConverterSorting: UnitsListSorting = UnitsListSorting.USAGE, +) + /** * Repository that works with DataStore */ @@ -112,7 +136,7 @@ class UserPreferencesRepository @Inject constructor(private val dataStore: DataS val UNIT_CONVERTER_SORTING = stringPreferencesKey("UNIT_CONVERTER_SORTING_PREF_KEY") } - val userPreferencesFlow: Flow = dataStore.data + val uiPreferencesFlow: Flow = dataStore.data .catch { exception -> if (exception is IOException) { emit(emptyPreferences()) @@ -128,6 +152,27 @@ class UserPreferencesRepository @Inject constructor(private val dataStore: DataS val customColor: Color = preferences[PrefsKeys.CUSTOM_COLOR]?.let { Color(it.toULong()) } ?: Color.Unspecified val monetMode: MonetMode = preferences[PrefsKeys.MONET_MODE]?.let { MonetMode.valueOf(it) } ?: MonetMode.TONAL_SPOT + val startingScreen: String = preferences[PrefsKeys.STARTING_SCREEN] ?: TopLevelDestinations.Converter.route + + UIPreferences( + themingMode = themingMode, + enableDynamicTheme = enableDynamicTheme, + enableAmoledTheme = enableAmoledTheme, + customColor = customColor, + monetMode = monetMode, + startingScreen = startingScreen + ) + } + + val mainPreferencesFlow: Flow = dataStore.data + .catch { exception -> + if (exception is IOException) { + emit(emptyPreferences()) + } else { + throw exception + } + } + .map { preferences -> val digitsPrecision: Int = preferences[PrefsKeys.DIGITS_PRECISION] ?: 3 val separator: Int = preferences[PrefsKeys.SEPARATOR] ?: Separator.SPACES val outputFormat: Int = preferences[PrefsKeys.OUTPUT_FORMAT] ?: OutputFormat.PLAIN @@ -147,19 +192,12 @@ class UserPreferencesRepository @Inject constructor(private val dataStore: DataS } ?: ALL_UNIT_GROUPS val enableVibrations: Boolean = preferences[PrefsKeys.ENABLE_VIBRATIONS] ?: true - val enableToolsExperiment: Boolean = preferences[PrefsKeys.ENABLE_TOOLS_EXPERIMENT] ?: false - val startingScreen: String = preferences[PrefsKeys.STARTING_SCREEN] ?: TopLevelDestinations.Converter.route val radianMode: Boolean = preferences[PrefsKeys.RADIAN_MODE] ?: true val unitConverterFavoritesOnly: Boolean = preferences[PrefsKeys.UNIT_CONVERTER_FAVORITES_ONLY] ?: false val unitConverterFormatTime: Boolean = preferences[PrefsKeys.UNIT_CONVERTER_FORMAT_TIME] ?: false val unitConverterSorting: UnitsListSorting = preferences[PrefsKeys.UNIT_CONVERTER_SORTING]?.let { UnitsListSorting.valueOf(it) } ?: UnitsListSorting.USAGE - UserPreferences( - themingMode = themingMode, - enableDynamicTheme = enableDynamicTheme, - enableAmoledTheme = enableAmoledTheme, - customColor = customColor, - monetMode = monetMode, + MainPreferences( digitsPrecision = digitsPrecision, separator = separator, outputFormat = outputFormat, @@ -167,8 +205,6 @@ class UserPreferencesRepository @Inject constructor(private val dataStore: DataS latestRightSideUnit = latestRightSideUnit, shownUnitGroups = shownUnitGroups, enableVibrations = enableVibrations, - enableToolsExperiment = enableToolsExperiment, - startingScreen = startingScreen, radianMode = radianMode, unitConverterFavoritesOnly = unitConverterFavoritesOnly, unitConverterFormatTime = unitConverterFormatTime, @@ -176,6 +212,31 @@ class UserPreferencesRepository @Inject constructor(private val dataStore: DataS ) } + val allPreferencesFlow = combine( + mainPreferencesFlow, uiPreferencesFlow + ) { main, ui -> + return@combine UserPreferences( + themingMode = ui.themingMode, + enableDynamicTheme = ui.enableDynamicTheme, + enableAmoledTheme = ui.enableAmoledTheme, + customColor = ui.customColor, + monetMode = ui.monetMode, + digitsPrecision = main.digitsPrecision, + separator = main.separator, + outputFormat = main.outputFormat, + latestLeftSideUnit = main.latestLeftSideUnit, + latestRightSideUnit = main.latestRightSideUnit, + shownUnitGroups = main.shownUnitGroups, + enableVibrations = main.enableVibrations, + enableToolsExperiment = false, + startingScreen = ui.startingScreen, + radianMode = main.radianMode, + unitConverterFavoritesOnly = main.unitConverterFavoritesOnly, + unitConverterFormatTime = main.unitConverterFormatTime, + unitConverterSorting = main.unitConverterSorting, + ) + } + /** * Update precision preference in DataStore * diff --git a/feature/calculator/build.gradle.kts b/feature/calculator/build.gradle.kts index b69ba212..d0055baf 100644 --- a/feature/calculator/build.gradle.kts +++ b/feature/calculator/build.gradle.kts @@ -29,7 +29,6 @@ android { dependencies { testImplementation(libs.junit) - implementation(libs.com.github.sadellie.themmo) implementation(project(mapOf("path" to ":data:common"))) implementation(project(mapOf("path" to ":data:userprefs"))) 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 79c15e10..80bc810f 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 @@ -31,7 +31,7 @@ import com.sadellie.unitto.data.common.isExpression import com.sadellie.unitto.data.common.setMinimumRequiredScale import com.sadellie.unitto.data.common.toStringWith import com.sadellie.unitto.data.common.trimZeros -import com.sadellie.unitto.data.userprefs.UserPreferences +import com.sadellie.unitto.data.userprefs.MainPreferences import com.sadellie.unitto.data.userprefs.UserPreferencesRepository import dagger.hilt.android.lifecycle.HiltViewModel import io.github.sadellie.evaluatto.Expression @@ -54,11 +54,11 @@ internal class CalculatorViewModel @Inject constructor( private val userPrefsRepository: UserPreferencesRepository, private val calculatorHistoryRepository: CalculatorHistoryRepository, ) : ViewModel() { - private val _userPrefs: StateFlow = - userPrefsRepository.userPreferencesFlow.stateIn( + private val _userPrefs: StateFlow = + userPrefsRepository.mainPreferencesFlow.stateIn( viewModelScope, SharingStarted.WhileSubscribed(5000L), - UserPreferences() + MainPreferences() ) private val _input: MutableStateFlow = MutableStateFlow(TextFieldValue()) diff --git a/feature/converter/build.gradle.kts b/feature/converter/build.gradle.kts index 22ab1a5b..91b5c875 100644 --- a/feature/converter/build.gradle.kts +++ b/feature/converter/build.gradle.kts @@ -36,7 +36,6 @@ dependencies { kapt(libs.androidx.room.compiler) testImplementation(libs.androidx.datastore) - implementation(libs.com.github.sadellie.themmo) implementation(libs.com.squareup.moshi) implementation(libs.com.squareup.retrofit2) diff --git a/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/ConverterViewModel.kt b/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/ConverterViewModel.kt index 746e0596..15bc07a3 100644 --- a/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/ConverterViewModel.kt +++ b/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/ConverterViewModel.kt @@ -39,7 +39,7 @@ import com.sadellie.unitto.data.units.MyUnitIDS import com.sadellie.unitto.data.units.combine import com.sadellie.unitto.data.units.remote.CurrencyApi import com.sadellie.unitto.data.units.remote.CurrencyUnitResponse -import com.sadellie.unitto.data.userprefs.UserPreferences +import com.sadellie.unitto.data.userprefs.MainPreferences import com.sadellie.unitto.data.userprefs.UserPreferencesRepository import dagger.hilt.android.lifecycle.HiltViewModel import io.github.sadellie.evaluatto.Expression @@ -65,10 +65,10 @@ class ConverterViewModel @Inject constructor( private val allUnitsRepository: AllUnitsRepository ) : ViewModel() { - private val _userPrefs = userPrefsRepository.userPreferencesFlow.stateIn( + private val _userPrefs = userPrefsRepository.mainPreferencesFlow.stateIn( viewModelScope, SharingStarted.WhileSubscribed(5000), - UserPreferences() + MainPreferences() ) /** @@ -334,7 +334,7 @@ class ConverterViewModel @Inject constructor( private fun loadInitialUnitPair() { viewModelScope.launch(Dispatchers.IO) { - val initialUserPrefs = userPrefsRepository.userPreferencesFlow.first() + val initialUserPrefs = userPrefsRepository.mainPreferencesFlow.first() // First we load latest pair of units _unitFrom.update { diff --git a/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/AboutScreen.kt b/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/AboutScreen.kt index 00ad327e..08d4b585 100644 --- a/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/AboutScreen.kt +++ b/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/AboutScreen.kt @@ -43,6 +43,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.sadellie.unitto.core.base.BuildConfig import com.sadellie.unitto.core.base.R @@ -54,7 +55,7 @@ import com.sadellie.unitto.core.ui.openLink internal fun AboutScreen( navigateUpAction: () -> Unit, navigateToThirdParty: () -> Unit, - viewModel: SettingsViewModel + viewModel: SettingsViewModel = hiltViewModel() ) { val mContext = LocalContext.current val userPrefs = viewModel.userPrefs.collectAsStateWithLifecycle() diff --git a/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/SettingsScreen.kt b/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/SettingsScreen.kt index 122588e6..1064c342 100644 --- a/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/SettingsScreen.kt +++ b/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/SettingsScreen.kt @@ -43,6 +43,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.sadellie.unitto.core.base.BuildConfig import com.sadellie.unitto.core.base.OUTPUT_FORMAT @@ -63,7 +64,7 @@ import com.sadellie.unitto.feature.settings.navigation.unitsGroupRoute @Composable internal fun SettingsScreen( - viewModel: SettingsViewModel, + viewModel: SettingsViewModel = hiltViewModel(), menuButtonClick: () -> Unit, navControllerAction: (String) -> Unit ) { diff --git a/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/SettingsViewModel.kt b/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/SettingsViewModel.kt index 488ab511..b0aef490 100644 --- a/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/SettingsViewModel.kt +++ b/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/SettingsViewModel.kt @@ -18,80 +18,27 @@ package com.sadellie.unitto.feature.settings -import androidx.compose.ui.graphics.Color import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.sadellie.unitto.data.model.UnitGroup import com.sadellie.unitto.data.model.UnitsListSorting -import com.sadellie.unitto.data.unitgroups.UnitGroupsRepository import com.sadellie.unitto.data.userprefs.UserPreferences import com.sadellie.unitto.data.userprefs.UserPreferencesRepository import dagger.hilt.android.lifecycle.HiltViewModel -import io.github.sadellie.themmo.MonetMode -import io.github.sadellie.themmo.ThemingMode import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import org.burnoutcrew.reorderable.ItemPosition import javax.inject.Inject @HiltViewModel class SettingsViewModel @Inject constructor( private val userPrefsRepository: UserPreferencesRepository, - private val unitGroupsRepository: UnitGroupsRepository, ) : ViewModel() { - val userPrefs = userPrefsRepository.userPreferencesFlow - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), + val userPrefs = userPrefsRepository.allPreferencesFlow + .stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5000), UserPreferences() ) - val shownUnitGroups = unitGroupsRepository.shownUnitGroups - val hiddenUnitGroups = unitGroupsRepository.hiddenUnitGroups - - /** - * @see UserPreferencesRepository.updateThemingMode - */ - fun updateThemingMode(themingMode: ThemingMode) { - viewModelScope.launch { - userPrefsRepository.updateThemingMode(themingMode) - } - } - - /** - * @see UserPreferencesRepository.updateDynamicTheme - */ - fun updateDynamicTheme(enabled: Boolean) { - viewModelScope.launch { - userPrefsRepository.updateDynamicTheme(enabled) - } - } - - /** - * @see UserPreferencesRepository.updateAmoledTheme - */ - fun updateAmoledTheme(enabled: Boolean) { - viewModelScope.launch { - userPrefsRepository.updateAmoledTheme(enabled) - } - } - - /** - * @see UserPreferencesRepository.updateCustomColor - */ - fun updateCustomColor(color: Color) { - viewModelScope.launch { - userPrefsRepository.updateCustomColor(color) - } - } - - /** - * @see UserPreferencesRepository.updateMonetMode - */ - fun updateMonetMode(monetMode: MonetMode) { - viewModelScope.launch { - userPrefsRepository.updateMonetMode(monetMode) - } - } /** * @see UserPreferencesRepository.updateDigitsPrecision @@ -138,46 +85,6 @@ class SettingsViewModel @Inject constructor( } } - /** - * @see UnitGroupsRepository.markUnitGroupAsHidden - * @see UserPreferencesRepository.updateShownUnitGroups - */ - fun hideUnitGroup(unitGroup: UnitGroup) { - viewModelScope.launch { - unitGroupsRepository.markUnitGroupAsHidden(unitGroup) - userPrefsRepository.updateShownUnitGroups(unitGroupsRepository.shownUnitGroups.value) - } - } - - /** - * @see UnitGroupsRepository.markUnitGroupAsShown - * @see UserPreferencesRepository.updateShownUnitGroups - */ - fun returnUnitGroup(unitGroup: UnitGroup) { - viewModelScope.launch { - unitGroupsRepository.markUnitGroupAsShown(unitGroup) - userPrefsRepository.updateShownUnitGroups(unitGroupsRepository.shownUnitGroups.value) - } - } - - /** - * @see UnitGroupsRepository.moveShownUnitGroups - */ - fun onMove(from: ItemPosition, to: ItemPosition) { - viewModelScope.launch { - unitGroupsRepository.moveShownUnitGroups(from, to) - } - } - - /** - * @see UserPreferencesRepository.updateShownUnitGroups - */ - fun onDragEnd() { - viewModelScope.launch { - userPrefsRepository.updateShownUnitGroups(unitGroupsRepository.shownUnitGroups.value) - } - } - /** * @see UserPreferencesRepository.updateToolsExperiment */ @@ -204,20 +111,4 @@ class SettingsViewModel @Inject constructor( userPrefsRepository.updateUnitConverterSorting(sorting) } } - - /** - * Prevent from dragging over non-draggable items (headers and hidden) - * - * @param pos Position we are dragging over. - * @return True if can drag over given item. - */ - fun canDragOver(pos: ItemPosition) = shownUnitGroups.value.any { it == pos.key } - - init { - viewModelScope.launch { - unitGroupsRepository.updateShownGroups( - userPrefsRepository.userPreferencesFlow.first().shownUnitGroups - ) - } - } } diff --git a/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/navigation/SettingsNavigation.kt b/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/navigation/SettingsNavigation.kt index cc2a874e..dc1e855b 100644 --- a/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/navigation/SettingsNavigation.kt +++ b/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/navigation/SettingsNavigation.kt @@ -27,10 +27,9 @@ import androidx.navigation.compose.navigation import com.sadellie.unitto.core.base.TopLevelDestinations import com.sadellie.unitto.feature.settings.AboutScreen import com.sadellie.unitto.feature.settings.SettingsScreen -import com.sadellie.unitto.feature.settings.SettingsViewModel -import com.sadellie.unitto.feature.settings.ThemesRoute +import com.sadellie.unitto.feature.settings.themes.ThemesRoute import com.sadellie.unitto.feature.settings.ThirdPartyLicensesScreen -import com.sadellie.unitto.feature.settings.UnitGroupsScreen +import com.sadellie.unitto.feature.settings.unitgroups.UnitGroupsScreen import io.github.sadellie.themmo.ThemmoController private val settingsGraph: String by lazy { TopLevelDestinations.Settings.route } @@ -49,7 +48,6 @@ fun NavController.navigateToUnitGroups() { } fun NavGraphBuilder.settingGraph( - settingsViewModel: SettingsViewModel, themmoController: ThemmoController, navController: NavHostController, menuButtonClick: () -> Unit @@ -57,37 +55,34 @@ fun NavGraphBuilder.settingGraph( navigation(settingsRoute, settingsGraph) { composable(settingsRoute) { SettingsScreen( - viewModel = settingsViewModel, - menuButtonClick = menuButtonClick - ) { route -> navController.navigate(route) } + menuButtonClick = menuButtonClick, + navControllerAction = navController::navigate + ) } composable(themesRoute) { ThemesRoute( - navigateUpAction = { navController.navigateUp() }, + navigateUpAction = navController::navigateUp, themmoController = themmoController, - viewModel = settingsViewModel ) } composable(thirdPartyRoute) { ThirdPartyLicensesScreen( - navigateUpAction = { navController.navigateUp() } + navigateUpAction = navController::navigateUp, ) } composable(aboutRoute) { AboutScreen( - navigateUpAction = { navController.navigateUp() }, + navigateUpAction = navController::navigateUp, navigateToThirdParty = { navController.navigate(thirdPartyRoute) }, - viewModel = settingsViewModel ) } composable(unitsGroupRoute) { UnitGroupsScreen( - viewModel = settingsViewModel, - navigateUpAction = { navController.navigateUp() } + navigateUpAction = navController::navigateUp, ) } } diff --git a/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/ThemesScreen.kt b/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/themes/ThemesScreen.kt similarity index 98% rename from feature/settings/src/main/java/com/sadellie/unitto/feature/settings/ThemesScreen.kt rename to feature/settings/src/main/java/com/sadellie/unitto/feature/settings/themes/ThemesScreen.kt index 72a49460..6448c2bc 100644 --- a/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/ThemesScreen.kt +++ b/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/themes/ThemesScreen.kt @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package com.sadellie.unitto.feature.settings +package com.sadellie.unitto.feature.settings.themes import android.os.Build import androidx.compose.animation.AnimatedVisibility @@ -49,6 +49,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel import com.sadellie.unitto.core.base.R import com.sadellie.unitto.core.ui.common.Header import com.sadellie.unitto.core.ui.common.NavigateUpButton @@ -82,7 +83,7 @@ private val colorSchemes: List by lazy { internal fun ThemesRoute( navigateUpAction: () -> Unit = {}, themmoController: ThemmoController, - viewModel: SettingsViewModel + viewModel: ThemesViewModel = hiltViewModel() ) { ThemesScreen( navigateUpAction = navigateUpAction, diff --git a/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/themes/ThemesViewModel.kt b/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/themes/ThemesViewModel.kt new file mode 100644 index 00000000..d28bb490 --- /dev/null +++ b/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/themes/ThemesViewModel.kt @@ -0,0 +1,80 @@ +/* + * 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.settings.themes + +import androidx.compose.ui.graphics.Color +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.sadellie.unitto.data.userprefs.UserPreferencesRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import io.github.sadellie.themmo.MonetMode +import io.github.sadellie.themmo.ThemingMode +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class ThemesViewModel @Inject constructor( + private val userPrefsRepository: UserPreferencesRepository +) : ViewModel() { + + /** + * @see UserPreferencesRepository.updateThemingMode + */ + fun updateThemingMode(themingMode: ThemingMode) { + viewModelScope.launch { + userPrefsRepository.updateThemingMode(themingMode) + } + } + + /** + * @see UserPreferencesRepository.updateDynamicTheme + */ + fun updateDynamicTheme(enabled: Boolean) { + viewModelScope.launch { + userPrefsRepository.updateDynamicTheme(enabled) + } + } + + /** + * @see UserPreferencesRepository.updateAmoledTheme + */ + fun updateAmoledTheme(enabled: Boolean) { + viewModelScope.launch { + userPrefsRepository.updateAmoledTheme(enabled) + } + } + + /** + * @see UserPreferencesRepository.updateCustomColor + */ + fun updateCustomColor(color: Color) { + viewModelScope.launch { + userPrefsRepository.updateCustomColor(color) + } + } + + /** + * @see UserPreferencesRepository.updateMonetMode + */ + fun updateMonetMode(monetMode: MonetMode) { + viewModelScope.launch { + userPrefsRepository.updateMonetMode(monetMode) + } + } +} diff --git a/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/UnitGroupsScreen.kt b/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/unitgroups/UnitGroupsScreen.kt similarity index 89% rename from feature/settings/src/main/java/com/sadellie/unitto/feature/settings/UnitGroupsScreen.kt rename to feature/settings/src/main/java/com/sadellie/unitto/feature/settings/unitgroups/UnitGroupsScreen.kt index 39e6e148..520631ee 100644 --- a/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/UnitGroupsScreen.kt +++ b/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/unitgroups/UnitGroupsScreen.kt @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package com.sadellie.unitto.feature.settings +package com.sadellie.unitto.feature.settings.unitgroups import androidx.compose.animation.animateColor import androidx.compose.animation.core.animateDp @@ -40,13 +40,14 @@ import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.sadellie.unitto.core.base.R import com.sadellie.unitto.core.ui.common.Header import com.sadellie.unitto.core.ui.common.NavigateUpButton @@ -59,21 +60,20 @@ import org.burnoutcrew.reorderable.reorderable @Composable internal fun UnitGroupsScreen( - viewModel: SettingsViewModel, + viewModel: UnitGroupsViewModel = hiltViewModel(), navigateUpAction: () -> Unit ) { + val uiState = viewModel.uiState.collectAsStateWithLifecycle() + UnittoScreenWithLargeTopBar( title = stringResource(R.string.unit_groups_setting), navigationIcon = { NavigateUpButton(navigateUpAction) } ) { paddingValues -> - val shownUnits = viewModel.shownUnitGroups.collectAsState() - val hiddenUnits = viewModel.hiddenUnitGroups.collectAsState() - val state = rememberReorderableLazyListState( - onMove = viewModel::onMove, + onMove = viewModel::moveShownUnitGroups, canDragOver = { from, _ -> viewModel.canDragOver(from) }, - onDragEnd = { _, _ -> viewModel.onDragEnd() } + onDragEnd = { _, _ -> viewModel.saveShownUnitGroups() } ) LazyColumn( @@ -89,7 +89,7 @@ internal fun UnitGroupsScreen( ) } - items(shownUnits.value, { it }) { item -> + items(uiState.value.shownGroups, { it }) { item -> ReorderableItem(state, key = item) { isDragging -> val transition = updateTransition(isDragging, label = "draggedTransition") val background by transition.animateColor(label = "background") { @@ -104,7 +104,7 @@ internal fun UnitGroupsScreen( modifier = Modifier .padding(horizontal = itemPadding) .clip(CircleShape) - .clickable { viewModel.hideUnitGroup(item) } + .clickable { viewModel.markUnitGroupAsHidden(item) } .detectReorderAfterLongPress(state), colors = ListItemDefaults.colors( containerColor = background @@ -117,7 +117,7 @@ internal fun UnitGroupsScreen( modifier = Modifier.clickable( interactionSource = remember { MutableInteractionSource() }, indication = rememberRipple(false), - onClick = { viewModel.hideUnitGroup(item) } + onClick = { viewModel.markUnitGroupAsHidden(item) } ) ) }, @@ -147,11 +147,11 @@ internal fun UnitGroupsScreen( ) } - items(hiddenUnits.value, { it }) { + items(uiState.value.hiddenGroups, { it }) { ListItem( modifier = Modifier .background(MaterialTheme.colorScheme.surface) - .clickable { viewModel.returnUnitGroup(it) } + .clickable { viewModel.markUnitGroupAsShown(it) } .animateItemPlacement(), headlineContent = { Text(stringResource(it.res)) }, trailingContent = { @@ -162,7 +162,7 @@ internal fun UnitGroupsScreen( modifier = Modifier.clickable( interactionSource = remember { MutableInteractionSource() }, indication = rememberRipple(false), - onClick = { viewModel.returnUnitGroup(it) } + onClick = { viewModel.markUnitGroupAsShown(it) } ) ) } diff --git a/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/unitgroups/UnitGroupsUIState.kt b/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/unitgroups/UnitGroupsUIState.kt new file mode 100644 index 00000000..3051e863 --- /dev/null +++ b/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/unitgroups/UnitGroupsUIState.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.settings.unitgroups + +import com.sadellie.unitto.data.model.UnitGroup + +data class UnitGroupsUIState( + val shownGroups: List, + val hiddenGroups: List +) diff --git a/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/unitgroups/UnitGroupsViewModel.kt b/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/unitgroups/UnitGroupsViewModel.kt new file mode 100644 index 00000000..3b6fd155 --- /dev/null +++ b/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/unitgroups/UnitGroupsViewModel.kt @@ -0,0 +1,135 @@ +/* + * 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.settings.unitgroups + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.sadellie.unitto.data.model.ALL_UNIT_GROUPS +import com.sadellie.unitto.data.model.UnitGroup +import com.sadellie.unitto.data.userprefs.UserPreferencesRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.burnoutcrew.reorderable.ItemPosition +import javax.inject.Inject + +@HiltViewModel +class UnitGroupsViewModel @Inject constructor( + private val userPreferencesRepository: UserPreferencesRepository, +) : ViewModel() { + + private var mutex: Mutex = Mutex() + + /** + * Currently shown [UnitGroup]s. + */ + private val _shownUnitGroups = MutableStateFlow(listOf()) + + /** + * Currently hidden [UnitGroup]s. + */ + private val _hiddenUnitGroups = MutableStateFlow(listOf()) + + init { + viewModelScope.launch { + val shown = userPreferencesRepository.mainPreferencesFlow.first().shownUnitGroups + mutex.withLock { + _shownUnitGroups.update { shown } + _hiddenUnitGroups.update { ALL_UNIT_GROUPS - shown.toSet() } + } + } + } + + val uiState = combine(_shownUnitGroups, _hiddenUnitGroups) { shown, hidden -> + return@combine UnitGroupsUIState( + shownGroups = shown, + hiddenGroups = hidden + ) + }.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5000L), + UnitGroupsUIState(emptyList(), emptyList()) + ) + + /** + * Moves [UnitGroup] from [_shownUnitGroups] to [_hiddenUnitGroups] + * + * @param unitGroup [UnitGroup] to hide. + */ + fun markUnitGroupAsHidden(unitGroup: UnitGroup) = viewModelScope.launch { + mutex.withLock { + _shownUnitGroups.update { it - unitGroup } + // Newly hidden unit will appear at the top of the list + _hiddenUnitGroups.update { listOf(unitGroup) + it } + } + } + + /** + * Moves [UnitGroup] from [_hiddenUnitGroups] to [_shownUnitGroups] + * + * @param unitGroup [UnitGroup] to show. + */ + fun markUnitGroupAsShown(unitGroup: UnitGroup) = viewModelScope.launch { + mutex.withLock { + _hiddenUnitGroups.update { it - unitGroup } + _shownUnitGroups.update { it + unitGroup } + } + } + + /** + * Moves [UnitGroup] in [_shownUnitGroups] from one index to another (reorder). + * + * @param from Position from which we need to move from + * @param to Position where to put [UnitGroup] + */ + fun moveShownUnitGroups(from: ItemPosition, to: ItemPosition) = viewModelScope.launch { + mutex.withLock { + _shownUnitGroups.update { shown -> + shown.toMutableList().apply { + val initialIndex = shown.indexOfFirst { it == from.key } + /** + * No such item. Happens when dragging item and clicking "remove" while item is + * still being dragged. + */ + if (initialIndex == -1) return@launch + + add( + shown.indexOfFirst { it == to.key }, + removeAt(initialIndex) + ) + } + } + } + } + + fun canDragOver(pos: ItemPosition) = uiState.value.shownGroups.any { it == pos.key } + + fun saveShownUnitGroups() = viewModelScope.launch { + userPreferencesRepository.updateShownUnitGroups( + uiState.value.shownGroups + ) + } +} diff --git a/feature/unitslist/src/main/java/com/sadellie/unitto/feature/unitslist/UnitsListViewModel.kt b/feature/unitslist/src/main/java/com/sadellie/unitto/feature/unitslist/UnitsListViewModel.kt index 483a2351..377517ea 100644 --- a/feature/unitslist/src/main/java/com/sadellie/unitto/feature/unitslist/UnitsListViewModel.kt +++ b/feature/unitslist/src/main/java/com/sadellie/unitto/feature/unitslist/UnitsListViewModel.kt @@ -26,9 +26,8 @@ import com.sadellie.unitto.data.database.UnitsEntity import com.sadellie.unitto.data.database.UnitsRepository import com.sadellie.unitto.data.model.AbstractUnit import com.sadellie.unitto.data.model.UnitGroup -import com.sadellie.unitto.data.unitgroups.UnitGroupsRepository import com.sadellie.unitto.data.units.AllUnitsRepository -import com.sadellie.unitto.data.userprefs.UserPreferences +import com.sadellie.unitto.data.userprefs.MainPreferences import com.sadellie.unitto.data.userprefs.UserPreferencesRepository import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers @@ -48,33 +47,30 @@ class UnitsListViewModel @Inject constructor( private val allUnitsRepository: AllUnitsRepository, private val mContext: Application, private val userPrefsRepository: UserPreferencesRepository, - unitGroupsRepository: UnitGroupsRepository, ) : ViewModel() { - private val _userPrefs: StateFlow = - userPrefsRepository.userPreferencesFlow.stateIn( + private val _userPrefs: StateFlow = + userPrefsRepository.mainPreferencesFlow.stateIn( viewModelScope, SharingStarted.WhileSubscribed(5000L), - UserPreferences() + MainPreferences() ) private val _unitsToShow = MutableStateFlow(emptyMap>()) private val _searchQuery = MutableStateFlow("") private val _chosenUnitGroup: MutableStateFlow = MutableStateFlow(null) - private val _shownUnitGroups = unitGroupsRepository.shownUnitGroups val mainFlow = combine( _userPrefs, _unitsToShow, _searchQuery, _chosenUnitGroup, - _shownUnitGroups, - ) { userPrefs, unitsToShow, searchQuery, chosenUnitGroup, shownUnitGroups -> + ) { userPrefs, unitsToShow, searchQuery, chosenUnitGroup -> return@combine SecondScreenUIState( favoritesOnly = userPrefs.unitConverterFavoritesOnly, unitsToShow = unitsToShow, searchQuery = searchQuery, chosenUnitGroup = chosenUnitGroup, - shownUnitGroups = shownUnitGroups, + shownUnitGroups = userPrefs.shownUnitGroups, formatterSymbols = AllFormatterSymbols.getById(userPrefs.separator) ) } @@ -136,7 +132,7 @@ class UnitsListViewModel @Inject constructor( chosenUnitGroup = _chosenUnitGroup.value, favoritesOnly = _userPrefs.value.unitConverterFavoritesOnly, searchQuery = _searchQuery.value, - allUnitsGroups = _shownUnitGroups.value, + allUnitsGroups = _userPrefs.value.shownUnitGroups, sorting = _userPrefs.value.unitConverterSorting ) From 275485d15a11697a3c57e19937a1b7c72c3c7b67 Mon Sep 17 00:00:00 2001 From: Sad Ellie Date: Fri, 19 May 2023 12:44:15 +0300 Subject: [PATCH 15/51] Clean up dependencies --- feature/datedifference/build.gradle.kts | 5 ----- feature/unitslist/build.gradle.kts | 2 -- 2 files changed, 7 deletions(-) diff --git a/feature/datedifference/build.gradle.kts b/feature/datedifference/build.gradle.kts index 297c0df5..57b8de18 100644 --- a/feature/datedifference/build.gradle.kts +++ b/feature/datedifference/build.gradle.kts @@ -29,9 +29,4 @@ android { dependencies { testImplementation(libs.junit) - implementation(libs.com.github.sadellie.themmo) - - implementation(project(mapOf("path" to ":data:common"))) - implementation(project(mapOf("path" to ":data:userprefs"))) - implementation(project(mapOf("path" to ":data:model"))) } diff --git a/feature/unitslist/build.gradle.kts b/feature/unitslist/build.gradle.kts index 6df670f4..e4009c16 100644 --- a/feature/unitslist/build.gradle.kts +++ b/feature/unitslist/build.gradle.kts @@ -28,8 +28,6 @@ android { } dependencies { - implementation(libs.com.github.sadellie.themmo) - implementation(project(mapOf("path" to ":data:model"))) implementation(project(mapOf("path" to ":data:userprefs"))) implementation(project(mapOf("path" to ":data:units"))) From 9444e55d71ba7ce360eccbc34ce699f774753030 Mon Sep 17 00:00:00 2001 From: Sad Ellie Date: Fri, 19 May 2023 17:46:26 +0300 Subject: [PATCH 16/51] I hate string manipulations --- .../common/textfield/ExpressionTransformer.kt | 31 +++++++------------ 1 file changed, 11 insertions(+), 20 deletions(-) diff --git a/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/textfield/ExpressionTransformer.kt b/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/textfield/ExpressionTransformer.kt index ce2df4fe..16481555 100644 --- a/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/textfield/ExpressionTransformer.kt +++ b/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/textfield/ExpressionTransformer.kt @@ -43,31 +43,22 @@ class ExpressionTransformer(private val formatterSymbols: FormatterSymbols) : Vi // original input is "1000" and cursor is placed at the end "1000|" // the formatted is "1,000" where cursor should be? - "1,000|" override fun originalToTransformed(offset: Int): Int { - val grouping = formatterSymbols.grouping.first() - val fixedCursor = unformatted.fixCursor(offset, formatterSymbols.grouping) - - // Unformatted always has "." (dot) as a fractional, we can't ues for checking with buffer, - // because it will fail when fractional is "," (comma) - val subString = formatted - .replace(formatterSymbols.grouping, "") - .replace(formatterSymbols.fractional, ".") - .take(offset) + val unformattedSubstr = unformatted.take(offset) var buffer = "" - var groupingCount = 0 - var cursor = 0 + var groupings = 0 - // we walk over formatted and stop when it matches unformatted (also counting grouping) - while (buffer != subString) { - val currentChar = formatted[cursor] - if (currentChar == grouping) { - groupingCount += 1 - } else { - buffer += currentChar + run { + formatted.forEach { + when (it) { + formatterSymbols.grouping.first() -> groupings++ + formatterSymbols.fractional.first() -> buffer += "." + else -> buffer += it + } + if (buffer == unformattedSubstr) return@run } - cursor++ } - return fixedCursor + groupingCount + return formatted.fixCursor(buffer.length + groupings, formatterSymbols.grouping) } // Called when clicking formatted text From 7ec8cf934ae5cbbc161d27c24d1b45b3642f12ac Mon Sep 17 00:00:00 2001 From: Sad Ellie Date: Sun, 21 May 2023 22:20:43 +0300 Subject: [PATCH 17/51] Interactive formatting settings --- .../sadellie/unitto/core/base/Precision.kt | 25 -- core/base/src/main/res/values/strings.xml | 7 +- .../unitto/core/ui/common/SegmentedButton.kt | 16 +- .../unitto/core/ui/common/UnittoSlider.kt | 157 ++++++++++++ feature/settings/build.gradle.kts | 1 + .../unitto/feature/settings/SettingsScreen.kt | 72 +----- .../feature/settings/SettingsViewModel.kt | 27 -- .../settings/formatting/FormattingScreen.kt | 242 ++++++++++++++++++ .../settings/formatting/FormattingUIState.kt | 26 ++ .../formatting/FormattingViewModel.kt | 113 ++++++++ .../settings/navigation/SettingsNavigation.kt | 10 +- 11 files changed, 568 insertions(+), 128 deletions(-) create mode 100644 core/ui/src/main/java/com/sadellie/unitto/core/ui/common/UnittoSlider.kt create mode 100644 feature/settings/src/main/java/com/sadellie/unitto/feature/settings/formatting/FormattingScreen.kt create mode 100644 feature/settings/src/main/java/com/sadellie/unitto/feature/settings/formatting/FormattingUIState.kt create mode 100644 feature/settings/src/main/java/com/sadellie/unitto/feature/settings/formatting/FormattingViewModel.kt diff --git a/core/base/src/main/java/com/sadellie/unitto/core/base/Precision.kt b/core/base/src/main/java/com/sadellie/unitto/core/base/Precision.kt index cc627f53..f929479d 100644 --- a/core/base/src/main/java/com/sadellie/unitto/core/base/Precision.kt +++ b/core/base/src/main/java/com/sadellie/unitto/core/base/Precision.kt @@ -22,28 +22,3 @@ package com.sadellie.unitto.core.base * Current maximum scale that will be used in app. Used in various place in code */ const val MAX_PRECISION: Int = 1_000 - -/** - * Currently available scale options - */ -val PRECISIONS: Map by lazy { - mapOf( - 0 to R.string.precision_zero, - 1 to R.string.precision_one, - 2 to R.string.precision_two, - 3 to R.string.precision_three, - 4 to R.string.precision_four, - 5 to R.string.precision_five, - 6 to R.string.precision_six, - 7 to R.string.precision_seven, - 8 to R.string.precision_eight, - 9 to R.string.precision_nine, - 10 to R.string.precision_ten, - 11 to R.string.precision_eleven, - 12 to R.string.precision_twelve, - 13 to R.string.precision_thirteen, - 14 to R.string.precision_fourteen, - 15 to R.string.precision_fifteen, - MAX_PRECISION to R.string.max_precision - ) -} diff --git a/core/base/src/main/res/values/strings.xml b/core/base/src/main/res/values/strings.xml index 0fcd661b..da26f6b7 100644 --- a/core/base/src/main/res/values/strings.xml +++ b/core/base/src/main/res/values/strings.xml @@ -1258,6 +1258,7 @@ Third party licenses Rate this app Formatting + Precision and numbers appearance Additional @@ -1306,9 +1307,9 @@ Group separator symbol - Period (42.069,12) - Comma (42,069.12) - Spaces (42 069.12) + Period + Comma + Spaces Result value formatting diff --git a/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/SegmentedButton.kt b/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/SegmentedButton.kt index 79d19737..58195781 100644 --- a/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/SegmentedButton.kt +++ b/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/SegmentedButton.kt @@ -66,7 +66,7 @@ fun RowScope.SegmentedButton( label: String, onClick: () -> Unit, selected: Boolean, - icon: ImageVector + icon: ImageVector? = null ) { val containerColor = if (selected) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.surface @@ -81,14 +81,16 @@ fun RowScope.SegmentedButton( ), contentPadding = PaddingValues(horizontal = 12.dp) ) { - Crossfade(targetState = selected) { - if (it) { - Icon(Icons.Default.Check, null, Modifier.size(18.dp)) - } else { - Icon(icon, null, Modifier.size(18.dp)) + if (icon != null) { + Crossfade(targetState = selected) { + if (it) { + Icon(Icons.Default.Check, null, Modifier.size(18.dp)) + } else { + Icon(icon, null, Modifier.size(18.dp)) + } } + Spacer(Modifier.width(8.dp)) } - Spacer(Modifier.width(8.dp)) Text(label) } } diff --git a/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/UnittoSlider.kt b/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/UnittoSlider.kt new file mode 100644 index 00000000..9ccbe731 --- /dev/null +++ b/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/UnittoSlider.kt @@ -0,0 +1,157 @@ +/* + * 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.core.ui.common + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Slider +import androidx.compose.material3.SliderPositions +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.ClipOp +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.drawscope.clipRect +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlin.math.ceil +import kotlin.math.roundToInt + +@Composable +fun UnittoSlider( + modifier: Modifier = Modifier, + value: Float, + valueRange: ClosedFloatingPointRange, + onValueChange: (Float) -> Unit, + onValueChangeFinished: (Float) -> Unit = {} +) { + val animated = animateFloatAsState(targetValue = value) + + Slider( + value = animated.value, + onValueChange = onValueChange, + modifier = modifier, + valueRange = valueRange, + onValueChangeFinished = { onValueChangeFinished(animated.value) }, + track = { sliderPosition -> SquigglyTrack(sliderPosition) }, + steps = valueRange.endInclusive.roundToInt(), + ) +} + +@Composable +private fun SquigglyTrack( + sliderPosition: SliderPositions, + eachWaveWidth: Float = 80f, + strokeWidth: Float = 15f, + filledColor: Color = MaterialTheme.colorScheme.primary, + unfilledColor: Color = MaterialTheme.colorScheme.surfaceVariant +) { + val coroutineScope = rememberCoroutineScope() + var direct by remember { mutableStateOf(1f) } + val animatedDirect = animateFloatAsState(direct, spring()) + val slider = sliderPosition.activeRange.endInclusive + + LaunchedEffect(sliderPosition.activeRange.endInclusive) { + coroutineScope.launch { + delay(300L) + direct = if (direct == 1f) -1f else 1f + } + } + + Canvas( + modifier = Modifier + .fillMaxWidth() + .height(20.dp) + ) { + val width = size.width + val height = size.height + + val path = Path().apply { + moveTo( + x = strokeWidth / 2, + y = height.times(0.5f) + ) + val amount = ceil(width.div(eachWaveWidth)) + + repeat(amount.toInt()) { + val peek = if (it % 2 == 0) animatedDirect.value else -animatedDirect.value + + relativeQuadraticBezierTo( + dx1 = eachWaveWidth * 0.5f, + // 0.75, because 1.0 was clipping out of bound for some reason + dy1 = height.times(0.75f) * peek, + dx2 = eachWaveWidth, + dy2 = 0f + ) + } + } + + clipRect( + top = 0f, + left = 0f, + right = width.times(slider), + bottom = height, + clipOp = ClipOp.Intersect + ) { + drawPath( + path = path, + color = filledColor, + style = Stroke(strokeWidth, cap = StrokeCap.Round) + ) + } + + drawLine( + color = unfilledColor, + start = Offset(width.times(slider), height.times(0.5f)), + end = Offset(width, height.times(0.5f)), + strokeWidth = strokeWidth, + cap = StrokeCap.Round + ) + } +} + +@Preview(device = "spec:width=411dp,height=891dp") +@Preview(device = "spec:width=673.5dp,height=841dp,dpi=480") +@Preview(device = "spec:width=1280dp,height=800dp,dpi=480") +@Preview(device = "spec:width=1920dp,height=1080dp,dpi=480") +@Composable +private fun PreviewNewSlider() { + var currentValue by remember { mutableStateOf(0.9f) } + + UnittoSlider( + value = currentValue, + valueRange = 0f..1f, + onValueChange = { currentValue = it } + ) +} diff --git a/feature/settings/build.gradle.kts b/feature/settings/build.gradle.kts index a8aa51e5..6c71d480 100644 --- a/feature/settings/build.gradle.kts +++ b/feature/settings/build.gradle.kts @@ -31,6 +31,7 @@ dependencies { implementation(libs.com.github.sadellie.themmo) implementation(libs.org.burnoutcrew.composereorderable) + implementation(project(mapOf("path" to ":data:common"))) implementation(project(mapOf("path" to ":data:model"))) implementation(project(mapOf("path" to ":data:unitgroups"))) implementation(project(mapOf("path" to ":data:userprefs"))) diff --git a/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/SettingsScreen.kt b/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/SettingsScreen.kt index 1064c342..194cd602 100644 --- a/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/SettingsScreen.kt +++ b/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/SettingsScreen.kt @@ -19,7 +19,6 @@ package com.sadellie.unitto.feature.settings import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Home @@ -42,14 +41,10 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.sadellie.unitto.core.base.BuildConfig -import com.sadellie.unitto.core.base.OUTPUT_FORMAT -import com.sadellie.unitto.core.base.PRECISIONS import com.sadellie.unitto.core.base.R -import com.sadellie.unitto.core.base.SEPARATORS import com.sadellie.unitto.core.base.TOP_LEVEL_DESTINATIONS import com.sadellie.unitto.core.ui.common.Header import com.sadellie.unitto.core.ui.common.MenuButton @@ -59,6 +54,7 @@ import com.sadellie.unitto.core.ui.openLink import com.sadellie.unitto.data.model.UnitsListSorting import com.sadellie.unitto.feature.settings.components.AlertDialogWithList import com.sadellie.unitto.feature.settings.navigation.aboutRoute +import com.sadellie.unitto.feature.settings.navigation.formattingRoute import com.sadellie.unitto.feature.settings.navigation.themesRoute import com.sadellie.unitto.feature.settings.navigation.unitsGroupRoute @@ -110,43 +106,18 @@ internal fun SettingsScreen( ) } - // GENERAL GROUP - item { Header(stringResource(R.string.formatting_settings_group)) } - - // PRECISION + // FORMATTING item { ListItem( leadingContent = { Icon( Icons.Default._123, - stringResource(R.string.precision_setting), + stringResource(R.string.formatting_settings_group), ) }, - headlineContent = { Text(stringResource(R.string.precision_setting)) }, - supportingContent = { Text(stringResource(R.string.precision_setting_support)) }, - modifier = Modifier.clickable { dialogState = DialogState.PRECISION } - ) - } - - // SEPARATOR - item { - ListItem( - headlineContent = { Text(stringResource(R.string.separator_setting)) }, - supportingContent = { Text(stringResource(R.string.separator_setting_support)) }, - modifier = Modifier - .clickable { dialogState = DialogState.SEPARATOR } - .padding(start = 40.dp) - ) - } - - // OUTPUT FORMAT - item { - ListItem( - headlineContent = { Text(stringResource(R.string.output_format_setting)) }, - supportingContent = { Text(stringResource(R.string.output_format_setting_support)) }, - modifier = Modifier - .clickable { dialogState = DialogState.OUTPUT_FORMAT } - .padding(start = 40.dp) + headlineContent = { Text(stringResource(R.string.formatting_settings_group)) }, + supportingContent = { Text(stringResource(R.string.formatting_settings_support)) }, + modifier = Modifier.clickable { navControllerAction(formattingRoute) } ) } @@ -260,35 +231,6 @@ internal fun SettingsScreen( // Showing dialog when (dialogState) { - DialogState.PRECISION -> { - AlertDialogWithList( - title = stringResource(R.string.precision_setting), - listItems = PRECISIONS, - selectedItemIndex = userPrefs.value.digitsPrecision, - selectAction = viewModel::updatePrecision, - dismissAction = { resetDialog() }, - supportText = stringResource(R.string.precision_setting_info) - ) - } - DialogState.SEPARATOR -> { - AlertDialogWithList( - title = stringResource(R.string.separator_setting), - listItems = SEPARATORS, - selectedItemIndex = userPrefs.value.separator, - selectAction = viewModel::updateSeparator, - dismissAction = { resetDialog() } - ) - } - DialogState.OUTPUT_FORMAT -> { - AlertDialogWithList( - title = stringResource(R.string.output_format_setting), - listItems = OUTPUT_FORMAT, - selectedItemIndex = userPrefs.value.outputFormat, - selectAction = viewModel::updateOutputFormat, - dismissAction = { resetDialog() }, - supportText = stringResource(R.string.output_format_setting_info) - ) - } DialogState.START_SCREEN -> { AlertDialogWithList( title = stringResource(R.string.starting_screen_setting), @@ -321,5 +263,5 @@ internal fun SettingsScreen( * All possible states for alert dialog that opens when user clicks on settings. */ private enum class DialogState { - NONE, PRECISION, SEPARATOR, OUTPUT_FORMAT, START_SCREEN, UNIT_LIST_SORTING + NONE, START_SCREEN, UNIT_LIST_SORTING } diff --git a/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/SettingsViewModel.kt b/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/SettingsViewModel.kt index b0aef490..d2f891c6 100644 --- a/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/SettingsViewModel.kt +++ b/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/SettingsViewModel.kt @@ -40,33 +40,6 @@ class SettingsViewModel @Inject constructor( UserPreferences() ) - /** - * @see UserPreferencesRepository.updateDigitsPrecision - */ - fun updatePrecision(precision: Int) { - viewModelScope.launch { - userPrefsRepository.updateDigitsPrecision(precision) - } - } - - /** - * @see UserPreferencesRepository.updateSeparator - */ - fun updateSeparator(separator: Int) { - viewModelScope.launch { - userPrefsRepository.updateSeparator(separator) - } - } - - /** - * @see UserPreferencesRepository.updateOutputFormat - */ - fun updateOutputFormat(outputFormat: Int) { - viewModelScope.launch { - userPrefsRepository.updateOutputFormat(outputFormat) - } - } - /** * @see UserPreferencesRepository.updateVibrations */ diff --git a/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/formatting/FormattingScreen.kt b/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/formatting/FormattingScreen.kt new file mode 100644 index 00000000..a799cecd --- /dev/null +++ b/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/formatting/FormattingScreen.kt @@ -0,0 +1,242 @@ +/* + * 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.settings.formatting + +import androidx.compose.foundation.background +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Architecture +import androidx.compose.material.icons.filled.EMobiledata +import androidx.compose.material.icons.filled._123 +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +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.res.stringResource +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.base.OUTPUT_FORMAT +import com.sadellie.unitto.core.base.OutputFormat +import com.sadellie.unitto.core.base.R +import com.sadellie.unitto.core.base.SEPARATORS +import com.sadellie.unitto.core.base.Separator +import com.sadellie.unitto.core.ui.common.NavigateUpButton +import com.sadellie.unitto.core.ui.common.UnittoSlider +import com.sadellie.unitto.core.ui.common.SegmentedButton +import com.sadellie.unitto.core.ui.common.SegmentedButtonsRow +import com.sadellie.unitto.core.ui.common.UnittoScreenWithLargeTopBar +import com.sadellie.unitto.core.ui.common.squashable +import com.sadellie.unitto.core.ui.theme.NumbersTextStyleDisplayMedium +import kotlin.math.roundToInt + +@Composable +fun FormattingRoute( + viewModel: FormattingViewModel = hiltViewModel(), + navigateUpAction: () -> Unit, +) { + val uiState = viewModel.uiState.collectAsStateWithLifecycle() + + FormattingScreen( + navigateUpAction = navigateUpAction, + uiState = uiState.value, + onPrecisionChange = viewModel::updatePrecision, + onSeparatorChange = viewModel::updateSeparator, + onOutputFormatChange = viewModel::updateOutputFormat, + togglePreview = viewModel::togglePreview + ) +} + +@Composable +fun FormattingScreen( + navigateUpAction: () -> Unit, + uiState: FormattingUIState, + onPrecisionChange: (Int) -> Unit, + onSeparatorChange: (Int) -> Unit, + onOutputFormatChange: (Int) -> Unit, + togglePreview: () -> Unit, + precisions: ClosedFloatingPointRange = 0f..16f, // 16th is a MAX_PRECISION (1000) +) { + UnittoScreenWithLargeTopBar( + title = stringResource(R.string.formatting_settings_group), + navigationIcon = { NavigateUpButton(navigateUpAction) }, + ) { paddingValues -> + LazyColumn( + modifier = Modifier + .padding(paddingValues) + ) { + item("preview") { + Column( + Modifier + .padding(16.dp) + .squashable( + onClick = togglePreview, + cornerRadiusRange = 8.dp..32.dp, + interactionSource = remember { MutableInteractionSource() } + ) + .background(MaterialTheme.colorScheme.secondaryContainer) + .fillMaxWidth() + .padding(16.dp) + ) { + Text( + text = "Preview (click to switch)", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSecondaryContainer + ) + Text( + text = uiState.preview, + style = NumbersTextStyleDisplayMedium, + maxLines = 1, + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()), + textAlign = TextAlign.End, + color = MaterialTheme.colorScheme.onSecondaryContainer + ) + } + } + + item("precision_label") { + ListItem( + leadingContent = { + Icon(Icons.Default.Architecture, stringResource(R.string.precision_setting)) + }, + headlineContent = { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth() + ) { + Text(stringResource(R.string.precision_setting)) + Text(if (uiState.precision >= precisions.endInclusive) stringResource(R.string.max_precision) else uiState.precision.toString()) + } + }, + supportingContent = { + Text(stringResource(R.string.precision_setting_support)) + } + ) + } + + item("precision_slider") { + UnittoSlider( + modifier = Modifier.padding(start = 56.dp, end = 16.dp), + value = uiState.precision.toFloat(), + valueRange = precisions, + onValueChange = { onPrecisionChange(it.roundToInt()) }, + ) + } + + item("separator_label") { + ListItem( + leadingContent = { + Icon(Icons.Default._123, stringResource(R.string.precision_setting)) + }, + headlineContent = { Text(stringResource(R.string.separator_setting)) }, + supportingContent = { Text(stringResource(R.string.separator_setting_support)) }, + ) + } + + item("separator") { + Row( + Modifier + .horizontalScroll(rememberScrollState()) + .wrapContentWidth() + .padding(start = 56.dp) + ) { + SegmentedButtonsRow { + SEPARATORS.forEach { (separator, stringRes) -> + SegmentedButton( + label = stringResource(stringRes), + onClick = { onSeparatorChange(separator) }, + selected = separator == uiState.separator + ) + } + } + } + } + + item("output_format_label") { + ListItem( + leadingContent = { + Icon(Icons.Default.EMobiledata, stringResource(R.string.precision_setting)) + }, + headlineContent = { Text(stringResource(R.string.output_format_setting)) }, + supportingContent = { Text(stringResource(R.string.output_format_setting_support)) } + ) + } + + item("output_format") { + Row( + Modifier + .horizontalScroll(rememberScrollState()) + .wrapContentWidth() + .padding(start = 56.dp) + ) { + SegmentedButtonsRow { + OUTPUT_FORMAT.forEach { (outputFormat, stringRes) -> + SegmentedButton( + label = stringResource(stringRes), + onClick = { onOutputFormatChange(outputFormat) }, + selected = outputFormat == uiState.outputFormat + ) + } + } + } + } + } + } +} + +@Preview +@Composable +private fun PreviewFormattingScreen() { + var currentPrecision by remember { mutableStateOf(6) } + var currentSeparator by remember { mutableStateOf(Separator.COMMA) } + var currentOutputFormat by remember { mutableStateOf(OutputFormat.PLAIN) } + + FormattingScreen( + uiState = FormattingUIState( + preview = "", + precision = 3, + separator = Separator.SPACES, + outputFormat = OutputFormat.PLAIN + ), + onPrecisionChange = { currentPrecision = it }, + onSeparatorChange = { currentSeparator = it }, + onOutputFormatChange = { currentOutputFormat = it }, + navigateUpAction = {}, + togglePreview = {} + ) +} diff --git a/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/formatting/FormattingUIState.kt b/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/formatting/FormattingUIState.kt new file mode 100644 index 00000000..0038e373 --- /dev/null +++ b/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/formatting/FormattingUIState.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.settings.formatting + +data class FormattingUIState( + val preview: String = "", + val precision: Int = 0, + val separator: Int? = null, + val outputFormat: Int? = null +) diff --git a/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/formatting/FormattingViewModel.kt b/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/formatting/FormattingViewModel.kt new file mode 100644 index 00000000..8dd66aa8 --- /dev/null +++ b/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/formatting/FormattingViewModel.kt @@ -0,0 +1,113 @@ +/* + * 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.settings.formatting + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.sadellie.unitto.core.base.MAX_PRECISION +import com.sadellie.unitto.core.ui.common.textfield.AllFormatterSymbols +import com.sadellie.unitto.core.ui.common.textfield.formatExpression +import com.sadellie.unitto.data.common.setMinimumRequiredScale +import com.sadellie.unitto.data.common.toStringWith +import com.sadellie.unitto.data.common.trimZeros +import com.sadellie.unitto.data.userprefs.UserPreferencesRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import java.math.BigDecimal +import javax.inject.Inject +import kotlin.math.ceil + +@HiltViewModel +class FormattingViewModel @Inject constructor( + private val userPreferencesRepository: UserPreferencesRepository +) : ViewModel() { + private val _mainPreferences = userPreferencesRepository.mainPreferencesFlow + private val _fractional = MutableStateFlow(false) + + val uiState = combine(_mainPreferences, _fractional) { mainPrefs, fractional -> + + return@combine FormattingUIState( + preview = updatePreview( + fractional = fractional, + precision = mainPrefs.digitsPrecision, + outputFormat = mainPrefs.outputFormat, + separator = mainPrefs.separator + ), + precision = mainPrefs.digitsPrecision, + separator = mainPrefs.separator, + outputFormat = mainPrefs.outputFormat + ) + } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000L), FormattingUIState()) + + fun togglePreview() = _fractional.update { !it } + + private fun updatePreview( + fractional: Boolean, + precision: Int, + outputFormat: Int, + separator: Int, + ): String { + val bigD = when { + fractional -> "0.${"0000001".padStart(precision, '0')}" + precision > 0 -> "123456.${"7890123456".repeat(ceil(precision.toDouble() / 10.0).toInt())}" + else -> "123456" + } + + return BigDecimal(bigD) + .setMinimumRequiredScale(precision) + .trimZeros() + .toStringWith(outputFormat) + .formatExpression(AllFormatterSymbols.getById(separator)) + } + + /** + * @see UserPreferencesRepository.updateDigitsPrecision + */ + fun updatePrecision(precision: Int) { + viewModelScope.launch { + // In UI the slider for precision goes from 0 to 16, where 16 is treated as 1000 (MAX) + val newPrecision = if (precision > 15) MAX_PRECISION else precision + userPreferencesRepository.updateDigitsPrecision(newPrecision) + } + } + + /** + * @see UserPreferencesRepository.updateSeparator + */ + fun updateSeparator(separator: Int) { + viewModelScope.launch { + userPreferencesRepository.updateSeparator(separator) + } + } + + /** + * @see UserPreferencesRepository.updateOutputFormat + */ + fun updateOutputFormat(outputFormat: Int) { + viewModelScope.launch { + userPreferencesRepository.updateOutputFormat(outputFormat) + } + } +} diff --git a/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/navigation/SettingsNavigation.kt b/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/navigation/SettingsNavigation.kt index dc1e855b..1d14d759 100644 --- a/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/navigation/SettingsNavigation.kt +++ b/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/navigation/SettingsNavigation.kt @@ -27,8 +27,9 @@ import androidx.navigation.compose.navigation import com.sadellie.unitto.core.base.TopLevelDestinations import com.sadellie.unitto.feature.settings.AboutScreen import com.sadellie.unitto.feature.settings.SettingsScreen -import com.sadellie.unitto.feature.settings.themes.ThemesRoute import com.sadellie.unitto.feature.settings.ThirdPartyLicensesScreen +import com.sadellie.unitto.feature.settings.formatting.FormattingRoute +import com.sadellie.unitto.feature.settings.themes.ThemesRoute import com.sadellie.unitto.feature.settings.unitgroups.UnitGroupsScreen import io.github.sadellie.themmo.ThemmoController @@ -38,6 +39,7 @@ internal const val themesRoute = "themes_route" internal const val unitsGroupRoute = "units_group_route" internal const val thirdPartyRoute = "third_party_route" internal const val aboutRoute = "about_route" +internal const val formattingRoute = "formatting_route" fun NavController.navigateToSettings(builder: NavOptionsBuilder.() -> Unit) { navigate(settingsRoute, builder) @@ -85,5 +87,11 @@ fun NavGraphBuilder.settingGraph( navigateUpAction = navController::navigateUp, ) } + + composable(formattingRoute) { + FormattingRoute( + navigateUpAction = navController::navigateUp + ) + } } } From 11f2d82b50de2ae5d8f4438abfc08535aeece502 Mon Sep 17 00:00:00 2001 From: Sad Ellie Date: Sun, 21 May 2023 23:05:18 +0300 Subject: [PATCH 18/51] Temporarily return old unit groups setting logic i tried to make it better and it broke --- .../unitgroups/UnitGroupsRepository .kt | 115 +++++++++++++++ .../settings/unitgroups/UnitGroupsScreen.kt | 23 +-- .../settings/unitgroups/UnitGroupsUIState.kt | 26 ---- .../unitgroups/UnitGroupsViewModel.kt | 139 ++++++------------ 4 files changed, 176 insertions(+), 127 deletions(-) create mode 100644 feature/settings/src/main/java/com/sadellie/unitto/feature/settings/unitgroups/UnitGroupsRepository .kt delete mode 100644 feature/settings/src/main/java/com/sadellie/unitto/feature/settings/unitgroups/UnitGroupsUIState.kt diff --git a/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/unitgroups/UnitGroupsRepository .kt b/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/unitgroups/UnitGroupsRepository .kt new file mode 100644 index 00000000..eb0decf0 --- /dev/null +++ b/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/unitgroups/UnitGroupsRepository .kt @@ -0,0 +1,115 @@ +/* + * 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.settings.unitgroups + +import com.sadellie.unitto.data.model.ALL_UNIT_GROUPS +import com.sadellie.unitto.data.model.UnitGroup +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.burnoutcrew.reorderable.ItemPosition +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Repository that holds information about shown and hidden [UnitGroup]s and provides methods to + * show/hide [UnitGroup]s. + */ +@Singleton +class UnitGroupsRepository @Inject constructor() { + + /** + * Mutex is need needed because we work with flow (sync stuff). + */ + private val mutex = Mutex() + + /** + * Currently shown [UnitGroup]s. + */ + var shownUnitGroups = MutableStateFlow(listOf()) + private set + + /** + * Currently hidden [UnitGroup]s. + */ + var hiddenUnitGroups = MutableStateFlow(listOf()) + private set + + /** + * Sets [shownUnitGroups] and updates [hiddenUnitGroups] as a side effect. [hiddenUnitGroups] is + * everything from [ALL_UNIT_GROUPS] that was not in [shownUnitGroups]. + * + * @param list List of [UnitGroup]s that need to be shown. + */ + suspend fun updateShownGroups(list: List) { + mutex.withLock { + shownUnitGroups.value = list + hiddenUnitGroups.value = ALL_UNIT_GROUPS - list.toSet() + } + } + + /** + * Moves [UnitGroup] from [shownUnitGroups] to [hiddenUnitGroups] + * + * @param unitGroup [UnitGroup] to hide. + */ + suspend fun markUnitGroupAsHidden(unitGroup: UnitGroup) { + mutex.withLock { + shownUnitGroups.value = shownUnitGroups.value - unitGroup + // Newly hidden unit will appear at the top of the list + hiddenUnitGroups.value = listOf(unitGroup) + hiddenUnitGroups.value + } + } + + /** + * Moves [UnitGroup] from [hiddenUnitGroups] to [shownUnitGroups] + * + * @param unitGroup [UnitGroup] to show. + */ + suspend fun markUnitGroupAsShown(unitGroup: UnitGroup) { + mutex.withLock { + hiddenUnitGroups.value = hiddenUnitGroups.value - unitGroup + shownUnitGroups.value = shownUnitGroups.value + unitGroup + } + } + + /** + * Moves [UnitGroup] in [shownUnitGroups] from one index to another (reorder). + * + * @param from Position from which we need to move from + * @param to Position where to put [UnitGroup] + */ + suspend fun moveShownUnitGroups(from: ItemPosition, to: ItemPosition) { + mutex.withLock { + shownUnitGroups.value = shownUnitGroups.value.toMutableList().apply { + val initialIndex = shownUnitGroups.value.indexOfFirst { it == from.key } + /** + * No such item. Happens when dragging item and clicking "remove" while item is + * still being dragged. + */ + if (initialIndex == -1) return + + add( + shownUnitGroups.value.indexOfFirst { it == to.key }, + removeAt(initialIndex) + ) + } + } + } +} diff --git a/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/unitgroups/UnitGroupsScreen.kt b/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/unitgroups/UnitGroupsScreen.kt index 520631ee..6956c7ed 100644 --- a/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/unitgroups/UnitGroupsScreen.kt +++ b/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/unitgroups/UnitGroupsScreen.kt @@ -40,6 +40,7 @@ import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier @@ -47,7 +48,6 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel -import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.sadellie.unitto.core.base.R import com.sadellie.unitto.core.ui.common.Header import com.sadellie.unitto.core.ui.common.NavigateUpButton @@ -63,17 +63,18 @@ internal fun UnitGroupsScreen( viewModel: UnitGroupsViewModel = hiltViewModel(), navigateUpAction: () -> Unit ) { - val uiState = viewModel.uiState.collectAsStateWithLifecycle() - UnittoScreenWithLargeTopBar( title = stringResource(R.string.unit_groups_setting), navigationIcon = { NavigateUpButton(navigateUpAction) } ) { paddingValues -> + val shownUnits = viewModel.shownUnitGroups.collectAsState() + val hiddenUnits = viewModel.hiddenUnitGroups.collectAsState() + val state = rememberReorderableLazyListState( - onMove = viewModel::moveShownUnitGroups, + onMove = viewModel::onMove, canDragOver = { from, _ -> viewModel.canDragOver(from) }, - onDragEnd = { _, _ -> viewModel.saveShownUnitGroups() } + onDragEnd = { _, _ -> viewModel.onDragEnd() } ) LazyColumn( @@ -89,7 +90,7 @@ internal fun UnitGroupsScreen( ) } - items(uiState.value.shownGroups, { it }) { item -> + items(shownUnits.value, { it }) { item -> ReorderableItem(state, key = item) { isDragging -> val transition = updateTransition(isDragging, label = "draggedTransition") val background by transition.animateColor(label = "background") { @@ -104,7 +105,7 @@ internal fun UnitGroupsScreen( modifier = Modifier .padding(horizontal = itemPadding) .clip(CircleShape) - .clickable { viewModel.markUnitGroupAsHidden(item) } + .clickable { viewModel.hideUnitGroup(item) } .detectReorderAfterLongPress(state), colors = ListItemDefaults.colors( containerColor = background @@ -117,7 +118,7 @@ internal fun UnitGroupsScreen( modifier = Modifier.clickable( interactionSource = remember { MutableInteractionSource() }, indication = rememberRipple(false), - onClick = { viewModel.markUnitGroupAsHidden(item) } + onClick = { viewModel.hideUnitGroup(item) } ) ) }, @@ -147,11 +148,11 @@ internal fun UnitGroupsScreen( ) } - items(uiState.value.hiddenGroups, { it }) { + items(hiddenUnits.value, { it }) { ListItem( modifier = Modifier .background(MaterialTheme.colorScheme.surface) - .clickable { viewModel.markUnitGroupAsShown(it) } + .clickable { viewModel.returnUnitGroup(it) } .animateItemPlacement(), headlineContent = { Text(stringResource(it.res)) }, trailingContent = { @@ -162,7 +163,7 @@ internal fun UnitGroupsScreen( modifier = Modifier.clickable( interactionSource = remember { MutableInteractionSource() }, indication = rememberRipple(false), - onClick = { viewModel.markUnitGroupAsShown(it) } + onClick = { viewModel.returnUnitGroup(it) } ) ) } diff --git a/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/unitgroups/UnitGroupsUIState.kt b/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/unitgroups/UnitGroupsUIState.kt deleted file mode 100644 index 3051e863..00000000 --- a/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/unitgroups/UnitGroupsUIState.kt +++ /dev/null @@ -1,26 +0,0 @@ -/* - * Unitto is a unit converter for Android - * Copyright (c) 2023 Elshan Agaev - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package com.sadellie.unitto.feature.settings.unitgroups - -import com.sadellie.unitto.data.model.UnitGroup - -data class UnitGroupsUIState( - val shownGroups: List, - val hiddenGroups: List -) diff --git a/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/unitgroups/UnitGroupsViewModel.kt b/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/unitgroups/UnitGroupsViewModel.kt index 3b6fd155..229f6119 100644 --- a/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/unitgroups/UnitGroupsViewModel.kt +++ b/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/unitgroups/UnitGroupsViewModel.kt @@ -20,116 +20,75 @@ package com.sadellie.unitto.feature.settings.unitgroups import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.sadellie.unitto.data.model.ALL_UNIT_GROUPS import com.sadellie.unitto.data.model.UnitGroup import com.sadellie.unitto.data.userprefs.UserPreferencesRepository import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock import org.burnoutcrew.reorderable.ItemPosition import javax.inject.Inject @HiltViewModel class UnitGroupsViewModel @Inject constructor( - private val userPreferencesRepository: UserPreferencesRepository, + private val userPrefsRepository: UserPreferencesRepository, + private val unitGroupsRepository: UnitGroupsRepository, ) : ViewModel() { - - private var mutex: Mutex = Mutex() + val shownUnitGroups = unitGroupsRepository.shownUnitGroups + val hiddenUnitGroups = unitGroupsRepository.hiddenUnitGroups /** - * Currently shown [UnitGroup]s. + * @see UnitGroupsRepository.markUnitGroupAsHidden + * @see UserPreferencesRepository.updateShownUnitGroups */ - private val _shownUnitGroups = MutableStateFlow(listOf()) + fun hideUnitGroup(unitGroup: UnitGroup) { + viewModelScope.launch { + unitGroupsRepository.markUnitGroupAsHidden(unitGroup) + userPrefsRepository.updateShownUnitGroups(unitGroupsRepository.shownUnitGroups.value) + } + } /** - * Currently hidden [UnitGroup]s. + * @see UnitGroupsRepository.markUnitGroupAsShown + * @see UserPreferencesRepository.updateShownUnitGroups */ - private val _hiddenUnitGroups = MutableStateFlow(listOf()) + fun returnUnitGroup(unitGroup: UnitGroup) { + viewModelScope.launch { + unitGroupsRepository.markUnitGroupAsShown(unitGroup) + userPrefsRepository.updateShownUnitGroups(unitGroupsRepository.shownUnitGroups.value) + } + } + + /** + * @see UnitGroupsRepository.moveShownUnitGroups + */ + fun onMove(from: ItemPosition, to: ItemPosition) { + viewModelScope.launch { + unitGroupsRepository.moveShownUnitGroups(from, to) + } + } + + /** + * @see UserPreferencesRepository.updateShownUnitGroups + */ + fun onDragEnd() { + viewModelScope.launch { + userPrefsRepository.updateShownUnitGroups(unitGroupsRepository.shownUnitGroups.value) + } + } + + /** + * Prevent from dragging over non-draggable items (headers and hidden) + * + * @param pos Position we are dragging over. + * @return True if can drag over given item. + */ + fun canDragOver(pos: ItemPosition) = shownUnitGroups.value.any { it == pos.key } init { viewModelScope.launch { - val shown = userPreferencesRepository.mainPreferencesFlow.first().shownUnitGroups - mutex.withLock { - _shownUnitGroups.update { shown } - _hiddenUnitGroups.update { ALL_UNIT_GROUPS - shown.toSet() } - } + unitGroupsRepository.updateShownGroups( + userPrefsRepository.mainPreferencesFlow.first().shownUnitGroups + ) } } - - val uiState = combine(_shownUnitGroups, _hiddenUnitGroups) { shown, hidden -> - return@combine UnitGroupsUIState( - shownGroups = shown, - hiddenGroups = hidden - ) - }.stateIn( - viewModelScope, - SharingStarted.WhileSubscribed(5000L), - UnitGroupsUIState(emptyList(), emptyList()) - ) - - /** - * Moves [UnitGroup] from [_shownUnitGroups] to [_hiddenUnitGroups] - * - * @param unitGroup [UnitGroup] to hide. - */ - fun markUnitGroupAsHidden(unitGroup: UnitGroup) = viewModelScope.launch { - mutex.withLock { - _shownUnitGroups.update { it - unitGroup } - // Newly hidden unit will appear at the top of the list - _hiddenUnitGroups.update { listOf(unitGroup) + it } - } - } - - /** - * Moves [UnitGroup] from [_hiddenUnitGroups] to [_shownUnitGroups] - * - * @param unitGroup [UnitGroup] to show. - */ - fun markUnitGroupAsShown(unitGroup: UnitGroup) = viewModelScope.launch { - mutex.withLock { - _hiddenUnitGroups.update { it - unitGroup } - _shownUnitGroups.update { it + unitGroup } - } - } - - /** - * Moves [UnitGroup] in [_shownUnitGroups] from one index to another (reorder). - * - * @param from Position from which we need to move from - * @param to Position where to put [UnitGroup] - */ - fun moveShownUnitGroups(from: ItemPosition, to: ItemPosition) = viewModelScope.launch { - mutex.withLock { - _shownUnitGroups.update { shown -> - shown.toMutableList().apply { - val initialIndex = shown.indexOfFirst { it == from.key } - /** - * No such item. Happens when dragging item and clicking "remove" while item is - * still being dragged. - */ - if (initialIndex == -1) return@launch - - add( - shown.indexOfFirst { it == to.key }, - removeAt(initialIndex) - ) - } - } - } - } - - fun canDragOver(pos: ItemPosition) = uiState.value.shownGroups.any { it == pos.key } - - fun saveShownUnitGroups() = viewModelScope.launch { - userPreferencesRepository.updateShownUnitGroups( - uiState.value.shownGroups - ) - } } From 4c727718b8098766403ff76553a4ab2a8eff2166 Mon Sep 17 00:00:00 2001 From: Sad Ellie Date: Tue, 23 May 2023 09:57:00 +0300 Subject: [PATCH 19/51] Fix proguard rules for retrofit --- data/units/consumer-rules.pro | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/data/units/consumer-rules.pro b/data/units/consumer-rules.pro index 2acdb785..d9087e3d 100644 --- a/data/units/consumer-rules.pro +++ b/data/units/consumer-rules.pro @@ -1,5 +1,15 @@ -repackageclasses +# https://github.com/square/retrofit/issues/3751#issuecomment-1192043644 +# Keep generic signature of Call, Response (R8 full mode strips signatures from non-kept items). + -keep,allowobfuscation,allowshrinking interface retrofit2.Call + -keep,allowobfuscation,allowshrinking class retrofit2.Response + + # With R8 full mode generic signatures are stripped for classes that are not + # kept. Suspend functions are wrapped in continuations where the type argument + # is used. + -keep,allowobfuscation,allowshrinking class kotlin.coroutines.Continuation + -keepclassmembers class ** { @com.squareup.moshi.FromJson *; @com.squareup.moshi.ToJson *; From 71c89d9062f0fbe9215b38350d2ba174aba8ff9d Mon Sep 17 00:00:00 2001 From: Sad Ellie Date: Tue, 23 May 2023 10:07:00 +0300 Subject: [PATCH 20/51] Adjust squiggly track --- .../com/sadellie/unitto/core/ui/common/UnittoSlider.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/UnittoSlider.kt b/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/UnittoSlider.kt index 9ccbe731..1a47d4e5 100644 --- a/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/UnittoSlider.kt +++ b/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/UnittoSlider.kt @@ -78,14 +78,14 @@ private fun SquigglyTrack( unfilledColor: Color = MaterialTheme.colorScheme.surfaceVariant ) { val coroutineScope = rememberCoroutineScope() - var direct by remember { mutableStateOf(1f) } + var direct by remember { mutableStateOf(0.72f) } val animatedDirect = animateFloatAsState(direct, spring()) val slider = sliderPosition.activeRange.endInclusive LaunchedEffect(sliderPosition.activeRange.endInclusive) { coroutineScope.launch { - delay(300L) - direct = if (direct == 1f) -1f else 1f + delay(200L) + direct *= -1 } } @@ -110,7 +110,7 @@ private fun SquigglyTrack( relativeQuadraticBezierTo( dx1 = eachWaveWidth * 0.5f, // 0.75, because 1.0 was clipping out of bound for some reason - dy1 = height.times(0.75f) * peek, + dy1 = height.times(peek), dx2 = eachWaveWidth, dy2 = 0f ) From 9a06fb4d75697f89f6eb409ab61c513972ca10c8 Mon Sep 17 00:00:00 2001 From: Sad Ellie Date: Tue, 23 May 2023 10:22:00 +0300 Subject: [PATCH 21/51] Squashable unit selection button --- .../core/ui/common/ModifierExtensions.kt | 4 +++ .../unitto/core/ui/common/UnittoButton.kt | 6 +++-- .../components/UnitSelectionButton.kt | 25 +++---------------- 3 files changed, 11 insertions(+), 24 deletions(-) diff --git a/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/ModifierExtensions.kt b/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/ModifierExtensions.kt index bca4b685..6bcd4add 100644 --- a/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/ModifierExtensions.kt +++ b/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/ModifierExtensions.kt @@ -37,6 +37,7 @@ import androidx.compose.ui.unit.Dp fun Modifier.squashable( onClick: () -> Unit = {}, onLongClick: (() -> Unit)? = null, + enabled: Boolean = true, interactionSource: MutableInteractionSource, cornerRadiusRange: IntRange, role: Role = Role.Button, @@ -55,12 +56,14 @@ fun Modifier.squashable( interactionSource = interactionSource, indication = rememberRipple(), role = role, + enabled = enabled ) } fun Modifier.squashable( onClick: () -> Unit = {}, onLongClick: (() -> Unit)? = null, + enabled: Boolean = true, interactionSource: MutableInteractionSource, cornerRadiusRange: ClosedRange, role: Role = Role.Button, @@ -79,5 +82,6 @@ fun Modifier.squashable( interactionSource = interactionSource, indication = rememberRipple(), role = role, + enabled = enabled ) } diff --git a/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/UnittoButton.kt b/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/UnittoButton.kt index 8416a49e..5890bc65 100644 --- a/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/UnittoButton.kt +++ b/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/UnittoButton.kt @@ -44,6 +44,7 @@ fun UnittoButton( modifier: Modifier = Modifier, onClick: () -> Unit, onLongClick: (() -> Unit)? = null, + enabled: Boolean = true, containerColor: Color, contentColor: Color = contentColorFor(containerColor), border: BorderStroke? = null, @@ -56,11 +57,12 @@ fun UnittoButton( onClick = onClick, onLongClick = onLongClick, interactionSource = interactionSource, - cornerRadiusRange = 30..50 + cornerRadiusRange = 30..50, + enabled = enabled ), color = containerColor, contentColor = contentColor, - border = border + border = border, ) { CompositionLocalProvider(LocalContentColor provides contentColor) { ProvideTextStyle(value = MaterialTheme.typography.labelLarge) { diff --git a/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/components/UnitSelectionButton.kt b/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/components/UnitSelectionButton.kt index 65a03503..40097f47 100644 --- a/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/components/UnitSelectionButton.kt +++ b/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/components/UnitSelectionButton.kt @@ -21,30 +21,22 @@ package com.sadellie.unitto.feature.converter.components import android.annotation.SuppressLint import androidx.compose.animation.AnimatedContent import androidx.compose.animation.SizeTransform -import androidx.compose.animation.core.FastOutSlowInEasing -import androidx.compose.animation.core.animateIntAsState -import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically import androidx.compose.animation.togetherWith -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.sadellie.unitto.core.base.R +import com.sadellie.unitto.core.ui.common.UnittoButton /** * Button to select a unit @@ -60,23 +52,12 @@ internal fun UnitSelectionButton( onClick: () -> Unit = {}, label: Int?, ) { - val interactionSource = remember { MutableInteractionSource() } - val isPressed by interactionSource.collectIsPressedAsState() - val cornerRadius: Int by animateIntAsState( - targetValue = if (isPressed) 30 else 50, - animationSpec = tween(easing = FastOutSlowInEasing), - ) - - Button( + UnittoButton( modifier = modifier, - shape = RoundedCornerShape(cornerRadius), onClick = onClick, enabled = label != null, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primaryContainer - ), + containerColor = MaterialTheme.colorScheme.primaryContainer, contentPadding = PaddingValues(vertical = 16.dp, horizontal = 8.dp), - interactionSource = interactionSource ) { AnimatedContent( targetState = label ?: 0, From f2a91e4dea3ef20091a71b661fea001824412aef Mon Sep 17 00:00:00 2001 From: Sad Ellie Date: Tue, 23 May 2023 11:07:50 +0300 Subject: [PATCH 22/51] Get rid of unit groups module --- data/unitgroups/.gitignore | 1 - data/unitgroups/build.gradle.kts | 34 -------------------- data/unitgroups/consumer-rules.pro | 0 data/unitgroups/src/main/AndroidManifest.xml | 22 ------------- feature/settings/build.gradle.kts | 1 - feature/unitslist/build.gradle.kts | 1 - settings.gradle.kts | 1 - 7 files changed, 60 deletions(-) delete mode 100644 data/unitgroups/.gitignore delete mode 100644 data/unitgroups/build.gradle.kts delete mode 100644 data/unitgroups/consumer-rules.pro delete mode 100644 data/unitgroups/src/main/AndroidManifest.xml diff --git a/data/unitgroups/.gitignore b/data/unitgroups/.gitignore deleted file mode 100644 index 42afabfd..00000000 --- a/data/unitgroups/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/build \ No newline at end of file diff --git a/data/unitgroups/build.gradle.kts b/data/unitgroups/build.gradle.kts deleted file mode 100644 index 0f477e00..00000000 --- a/data/unitgroups/build.gradle.kts +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Unitto is a unit converter for Android - * Copyright (c) 2023 Elshan Agaev - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -plugins { - id("unitto.library") - id("unitto.android.hilt") -} - -android { - namespace = "com.sadellie.unitto.data.unitgroups" -} - -dependencies { - testImplementation(libs.junit) - implementation(libs.org.burnoutcrew.composereorderable) - - implementation(project(mapOf("path" to ":core:base"))) - implementation(project(mapOf("path" to ":data:model"))) -} diff --git a/data/unitgroups/consumer-rules.pro b/data/unitgroups/consumer-rules.pro deleted file mode 100644 index e69de29b..00000000 diff --git a/data/unitgroups/src/main/AndroidManifest.xml b/data/unitgroups/src/main/AndroidManifest.xml deleted file mode 100644 index 7bdbce91..00000000 --- a/data/unitgroups/src/main/AndroidManifest.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/feature/settings/build.gradle.kts b/feature/settings/build.gradle.kts index 6c71d480..9badfbd5 100644 --- a/feature/settings/build.gradle.kts +++ b/feature/settings/build.gradle.kts @@ -33,7 +33,6 @@ dependencies { implementation(project(mapOf("path" to ":data:common"))) implementation(project(mapOf("path" to ":data:model"))) - implementation(project(mapOf("path" to ":data:unitgroups"))) implementation(project(mapOf("path" to ":data:userprefs"))) implementation(project(mapOf("path" to ":data:licenses"))) } diff --git a/feature/unitslist/build.gradle.kts b/feature/unitslist/build.gradle.kts index e4009c16..065310e6 100644 --- a/feature/unitslist/build.gradle.kts +++ b/feature/unitslist/build.gradle.kts @@ -32,5 +32,4 @@ dependencies { implementation(project(mapOf("path" to ":data:userprefs"))) implementation(project(mapOf("path" to ":data:units"))) implementation(project(mapOf("path" to ":data:database"))) - implementation(project(mapOf("path" to ":data:unitgroups"))) } \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 69bc9f2f..a9bf3582 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -27,7 +27,6 @@ include(":feature:calculator") include(":feature:datedifference") include(":feature:settings") include(":data:userprefs") -include(":data:unitgroups") include(":data:licenses") // include(":data:epoch") include(":data:calculator") From d3a1d697dc8fe4097f447fcd7c9b93ff80ac5b3e Mon Sep 17 00:00:00 2001 From: Sad Ellie Date: Tue, 23 May 2023 11:57:53 +0300 Subject: [PATCH 23/51] Adjust text size for date difference screen --- .../feature/datedifference/components/DateTimeResultBlock.kt | 2 +- .../datedifference/components/DateTimeSelectorBlock.kt | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/feature/datedifference/src/main/java/com/sadellie/unitto/feature/datedifference/components/DateTimeResultBlock.kt b/feature/datedifference/src/main/java/com/sadellie/unitto/feature/datedifference/components/DateTimeResultBlock.kt index 29001a14..98178b13 100644 --- a/feature/datedifference/src/main/java/com/sadellie/unitto/feature/datedifference/components/DateTimeResultBlock.kt +++ b/feature/datedifference/src/main/java/com/sadellie/unitto/feature/datedifference/components/DateTimeResultBlock.kt @@ -109,7 +109,7 @@ internal fun DateTimeResultBlock( enter = expandVertically(), exit = shrinkVertically() ) { - Text(it, style = MaterialTheme.typography.displayMedium) + Text(it, style = MaterialTheme.typography.displaySmall) } } } diff --git a/feature/datedifference/src/main/java/com/sadellie/unitto/feature/datedifference/components/DateTimeSelectorBlock.kt b/feature/datedifference/src/main/java/com/sadellie/unitto/feature/datedifference/components/DateTimeSelectorBlock.kt index 8513178e..55c913c0 100644 --- a/feature/datedifference/src/main/java/com/sadellie/unitto/feature/datedifference/components/DateTimeSelectorBlock.kt +++ b/feature/datedifference/src/main/java/com/sadellie/unitto/feature/datedifference/components/DateTimeSelectorBlock.kt @@ -67,7 +67,7 @@ internal fun DateTimeSelectorBlock( onClick = onTimeClick ), text = dateTime.format(time24Formatter), - style = MaterialTheme.typography.displayMedium, + style = MaterialTheme.typography.displaySmall, maxLines = 1 ) } else { @@ -80,7 +80,7 @@ internal fun DateTimeSelectorBlock( ) { Text( text = dateTime.format(time12Formatter), - style = MaterialTheme.typography.displayMedium, + style = MaterialTheme.typography.displaySmall, maxLines = 1 ) Text( From 6922db9d6ea4628717bba763c26e1a31fd6bcfd4 Mon Sep 17 00:00:00 2001 From: Sad Ellie Date: Tue, 23 May 2023 15:17:36 +0300 Subject: [PATCH 24/51] Refactor date difference business and UI logic * changed date difference calculation algorithm * added tests * added years --- core/base/src/main/res/values/strings.xml | 1 + .../feature/datedifference/DateDifference.kt | 44 +++++++++++----- .../datedifference/DateDifferenceScreen.kt | 2 +- .../components/DateTimeResultBlock.kt | 32 ++++++------ .../datedifference/DateDifferenceKtTest.kt | 51 +++++++++++++++++++ 5 files changed, 100 insertions(+), 30 deletions(-) create mode 100644 feature/datedifference/src/test/java/com/sadellie/unitto/feature/datedifference/DateDifferenceKtTest.kt diff --git a/core/base/src/main/res/values/strings.xml b/core/base/src/main/res/values/strings.xml index da26f6b7..e5911fb2 100644 --- a/core/base/src/main/res/values/strings.xml +++ b/core/base/src/main/res/values/strings.xml @@ -1278,6 +1278,7 @@ Start End Difference + Years Months Days Hours diff --git a/feature/datedifference/src/main/java/com/sadellie/unitto/feature/datedifference/DateDifference.kt b/feature/datedifference/src/main/java/com/sadellie/unitto/feature/datedifference/DateDifference.kt index 3351825a..87c748a4 100644 --- a/feature/datedifference/src/main/java/com/sadellie/unitto/feature/datedifference/DateDifference.kt +++ b/feature/datedifference/src/main/java/com/sadellie/unitto/feature/datedifference/DateDifference.kt @@ -18,21 +18,24 @@ package com.sadellie.unitto.feature.datedifference -import java.time.Duration import java.time.LocalDateTime +import java.time.temporal.ChronoUnit internal sealed class DateDifference( + open val years: Long = 0, open val months: Long = 0, open val days: Long = 0, open val hours: Long = 0, open val minutes: Long = 0, ) { data class Default( + override val years: Long = 0, override val months: Long = 0, override val days: Long = 0, override val hours: Long = 0, override val minutes: Long = 0, ) : DateDifference( + years = years, months = months, days = days, hours = hours, @@ -42,23 +45,38 @@ internal sealed class DateDifference( object Zero : DateDifference() } +// https://stackoverflow.com/a/25760725 internal infix operator fun LocalDateTime.minus(localDateTime: LocalDateTime): DateDifference { - val duration = Duration.between(this, localDateTime).abs() + if (this == localDateTime) return DateDifference.Zero - if (duration.isZero) return DateDifference.Zero + var fromDateTime: LocalDateTime = this + var toDateTime: LocalDateTime = localDateTime - val durationDays = duration.toDays() - val months = durationDays / 30 - val days = durationDays % 30 - val hours = duration.toHoursPart().toLong() - val minutes = duration.toMinutesPart().toLong() + // Swap to avoid negative + if (this > localDateTime) { + fromDateTime = localDateTime + toDateTime = this + } - if (listOf(months, days, hours, minutes).all { it == 0L }) return DateDifference.Zero + var tempDateTime = LocalDateTime.from(fromDateTime) + + val years = tempDateTime.until(toDateTime, ChronoUnit.YEARS) + + tempDateTime = tempDateTime.plusYears(years) + val months = tempDateTime.until(toDateTime, ChronoUnit.MONTHS) + + tempDateTime = tempDateTime.plusMonths(months) + val days = tempDateTime.until(toDateTime, ChronoUnit.DAYS) + + tempDateTime = tempDateTime.plusDays(days) + val hours = tempDateTime.until(toDateTime, ChronoUnit.HOURS) + + tempDateTime = tempDateTime.plusHours(hours) + val minutes = tempDateTime.until(toDateTime, ChronoUnit.MINUTES) + + if (listOf(years, months, days, hours, minutes).sum() == 0L) return DateDifference.Zero return DateDifference.Default( - months = months, - days = days, - hours = hours, - minutes = minutes + years = years, months = months, days = days, hours = hours, minutes = minutes ) } diff --git a/feature/datedifference/src/main/java/com/sadellie/unitto/feature/datedifference/DateDifferenceScreen.kt b/feature/datedifference/src/main/java/com/sadellie/unitto/feature/datedifference/DateDifferenceScreen.kt index 69f19b26..fdc56887 100644 --- a/feature/datedifference/src/main/java/com/sadellie/unitto/feature/datedifference/DateDifferenceScreen.kt +++ b/feature/datedifference/src/main/java/com/sadellie/unitto/feature/datedifference/DateDifferenceScreen.kt @@ -218,7 +218,7 @@ private fun DateDifferenceScreenPreview() { updateStart = {}, updateEnd = {}, uiState = UIState( - result = DateDifference.Default(1, 2, 3, 4) + result = DateDifference.Default(4, 1, 2, 3, 4) ) ) } diff --git a/feature/datedifference/src/main/java/com/sadellie/unitto/feature/datedifference/components/DateTimeResultBlock.kt b/feature/datedifference/src/main/java/com/sadellie/unitto/feature/datedifference/components/DateTimeResultBlock.kt index 98178b13..2b841809 100644 --- a/feature/datedifference/src/main/java/com/sadellie/unitto/feature/datedifference/components/DateTimeResultBlock.kt +++ b/feature/datedifference/src/main/java/com/sadellie/unitto/feature/datedifference/components/DateTimeResultBlock.kt @@ -18,6 +18,7 @@ package com.sadellie.unitto.feature.datedifference.components +import androidx.annotation.StringRes import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.expandVertically import androidx.compose.animation.shrinkVertically @@ -35,6 +36,7 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -54,23 +56,13 @@ internal fun DateTimeResultBlock( ) { val clipboardManager = LocalClipboardManager.current - val months = if (dateDifference.months > 0) { - "${stringResource(R.string.date_difference_months)}: ${dateDifference.months}" - } else "" + val years = dateDifference.years.formatDateTimeValue(R.string.date_difference_years) + val months = dateDifference.months.formatDateTimeValue(R.string.date_difference_months) + val days = dateDifference.days.formatDateTimeValue(R.string.date_difference_days) + val hours = dateDifference.hours.formatDateTimeValue(R.string.date_difference_hours) + val minutes = dateDifference.minutes.formatDateTimeValue(R.string.date_difference_minutes) - val days = if (dateDifference.days > 0) { - "${stringResource(R.string.date_difference_days)}: ${dateDifference.days}" - } else "" - - val hours = if (dateDifference.hours > 0) { - "${stringResource(R.string.date_difference_hours)}: ${dateDifference.hours}" - } else "" - - val minutes = if (dateDifference.minutes > 0) { - "${stringResource(R.string.date_difference_minutes)}: ${dateDifference.minutes}" - } else "" - - val texts = listOf(months, days, hours, minutes) + val texts = listOf(years, months, days, hours, minutes) Column( modifier = modifier @@ -115,6 +107,14 @@ internal fun DateTimeResultBlock( } } +@Composable +@ReadOnlyComposable +private fun Long.formatDateTimeValue(@StringRes id: Int): String { + if (this <= 0) return "" + + return "${stringResource(id)}: $this" +} + @Preview @Composable private fun PreviewCard() { diff --git a/feature/datedifference/src/test/java/com/sadellie/unitto/feature/datedifference/DateDifferenceKtTest.kt b/feature/datedifference/src/test/java/com/sadellie/unitto/feature/datedifference/DateDifferenceKtTest.kt new file mode 100644 index 00000000..b7e8162b --- /dev/null +++ b/feature/datedifference/src/test/java/com/sadellie/unitto/feature/datedifference/DateDifferenceKtTest.kt @@ -0,0 +1,51 @@ +/* + * 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.datedifference + +import org.junit.Assert.assertEquals +import org.junit.Test +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter + +class DateDifferenceKtTest { + private val fromatt: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm") + private val `may 1 2023`: LocalDateTime = LocalDateTime.parse("2023-05-01 12:00", fromatt) + private val `may 2 2023`: LocalDateTime = LocalDateTime.parse("2023-05-02 12:00", fromatt) + private val `june 1 2023`: LocalDateTime = LocalDateTime.parse("2023-06-01 12:00", fromatt) + + @Test + fun `same dates`() { + assertEquals(DateDifference.Zero, `may 1 2023` - `may 1 2023`) + } + + @Test + fun `positive difference dates one day`() { + assertEquals(DateDifference.Default(days = 1), `may 1 2023` - `may 2 2023`) + } + + @Test + fun `positive difference dates one minth`() { + assertEquals(DateDifference.Default(months = 1), `may 1 2023` - `june 1 2023`) + } + + @Test + fun `negative difference dates one day`() { + assertEquals(DateDifference.Default(days = 1), `may 2 2023` - `may 1 2023`) + } +} From 6bb6afcbcf7465c0721eaedca75bfdb36b5d0700 Mon Sep 17 00:00:00 2001 From: Sad Ellie Date: Tue, 23 May 2023 17:15:26 +0300 Subject: [PATCH 25/51] Improve slider track animation --- .../java/com/sadellie/unitto/core/ui/common/UnittoSlider.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/UnittoSlider.kt b/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/UnittoSlider.kt index 1a47d4e5..73fc9141 100644 --- a/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/UnittoSlider.kt +++ b/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/UnittoSlider.kt @@ -18,6 +18,7 @@ package com.sadellie.unitto.core.ui.common +import androidx.compose.animation.core.Spring import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.spring import androidx.compose.foundation.Canvas @@ -79,7 +80,7 @@ private fun SquigglyTrack( ) { val coroutineScope = rememberCoroutineScope() var direct by remember { mutableStateOf(0.72f) } - val animatedDirect = animateFloatAsState(direct, spring()) + val animatedDirect = animateFloatAsState(direct, spring(stiffness = Spring.StiffnessLow)) val slider = sliderPosition.activeRange.endInclusive LaunchedEffect(sliderPosition.activeRange.endInclusive) { From bf79ca54f6451223b7abe0d3a696ced095674576 Mon Sep 17 00:00:00 2001 From: Sad Ellie Date: Tue, 23 May 2023 23:06:08 +0300 Subject: [PATCH 26/51] Inline formatting options --- .../sadellie/unitto/core/base/OutputFormat.kt | 11 ----- .../sadellie/unitto/core/base/Separator.kt | 11 ----- .../settings/formatting/FormattingScreen.kt | 46 ++++++++++++------- 3 files changed, 30 insertions(+), 38 deletions(-) diff --git a/core/base/src/main/java/com/sadellie/unitto/core/base/OutputFormat.kt b/core/base/src/main/java/com/sadellie/unitto/core/base/OutputFormat.kt index 77d1b9b6..c3002585 100644 --- a/core/base/src/main/java/com/sadellie/unitto/core/base/OutputFormat.kt +++ b/core/base/src/main/java/com/sadellie/unitto/core/base/OutputFormat.kt @@ -29,14 +29,3 @@ object OutputFormat { // App will try it's best to use engineering notation const val FORCE_ENGINEERING = 2 } - -/** - * Available formats. Used in settings - */ -val OUTPUT_FORMAT: Map by lazy { - mapOf( - OutputFormat.PLAIN to R.string.plain, - OutputFormat.ALLOW_ENGINEERING to R.string.allow_engineering, - OutputFormat.FORCE_ENGINEERING to R.string.force_engineering, - ) -} diff --git a/core/base/src/main/java/com/sadellie/unitto/core/base/Separator.kt b/core/base/src/main/java/com/sadellie/unitto/core/base/Separator.kt index f39294a7..7eae5336 100644 --- a/core/base/src/main/java/com/sadellie/unitto/core/base/Separator.kt +++ b/core/base/src/main/java/com/sadellie/unitto/core/base/Separator.kt @@ -26,14 +26,3 @@ object Separator { const val PERIOD = 1 const val COMMA = 2 } - -/** - * Map of separators that is used in settings - */ -val SEPARATORS: Map by lazy { - mapOf( - Separator.SPACES to R.string.spaces, - Separator.PERIOD to R.string.period, - Separator.COMMA to R.string.comma - ) -} diff --git a/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/formatting/FormattingScreen.kt b/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/formatting/FormattingScreen.kt index a799cecd..7f1a2940 100644 --- a/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/formatting/FormattingScreen.kt +++ b/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/formatting/FormattingScreen.kt @@ -49,10 +49,8 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.sadellie.unitto.core.base.OUTPUT_FORMAT import com.sadellie.unitto.core.base.OutputFormat import com.sadellie.unitto.core.base.R -import com.sadellie.unitto.core.base.SEPARATORS import com.sadellie.unitto.core.base.Separator import com.sadellie.unitto.core.ui.common.NavigateUpButton import com.sadellie.unitto.core.ui.common.UnittoSlider @@ -176,13 +174,21 @@ fun FormattingScreen( .padding(start = 56.dp) ) { SegmentedButtonsRow { - SEPARATORS.forEach { (separator, stringRes) -> - SegmentedButton( - label = stringResource(stringRes), - onClick = { onSeparatorChange(separator) }, - selected = separator == uiState.separator - ) - } + SegmentedButton( + label = stringResource(R.string.spaces), + onClick = { onSeparatorChange(Separator.SPACES) }, + selected = Separator.SPACES == uiState.separator + ) + SegmentedButton( + label = stringResource(R.string.period), + onClick = { onSeparatorChange(Separator.PERIOD) }, + selected = Separator.PERIOD == uiState.separator + ) + SegmentedButton( + label = stringResource(R.string.comma), + onClick = { onSeparatorChange(Separator.COMMA) }, + selected = Separator.COMMA == uiState.separator + ) } } } @@ -205,13 +211,21 @@ fun FormattingScreen( .padding(start = 56.dp) ) { SegmentedButtonsRow { - OUTPUT_FORMAT.forEach { (outputFormat, stringRes) -> - SegmentedButton( - label = stringResource(stringRes), - onClick = { onOutputFormatChange(outputFormat) }, - selected = outputFormat == uiState.outputFormat - ) - } + SegmentedButton( + label = stringResource(R.string.plain), + onClick = { onOutputFormatChange(OutputFormat.PLAIN) }, + selected = OutputFormat.PLAIN == uiState.outputFormat + ) + SegmentedButton( + label = stringResource(R.string.allow_engineering), + onClick = { onOutputFormatChange(OutputFormat.ALLOW_ENGINEERING) }, + selected = OutputFormat.ALLOW_ENGINEERING == uiState.outputFormat + ) + SegmentedButton( + label = stringResource(R.string.force_engineering), + onClick = { onOutputFormatChange(OutputFormat.FORCE_ENGINEERING) }, + selected = OutputFormat.FORCE_ENGINEERING == uiState.outputFormat + ) } } } From 781d6b350afe4d4b1e3b818ed226aab2b42a2502 Mon Sep 17 00:00:00 2001 From: Sad Ellie Date: Tue, 23 May 2023 23:46:19 +0300 Subject: [PATCH 27/51] Sync localizations with POEditor --- core/base/src/main/res/values-de/strings.xml | 395 ++- .../src/main/res/values-en-rGB/strings.xml | 64 +- core/base/src/main/res/values-fr/strings.xml | 9 +- core/base/src/main/res/values-it/strings.xml | 1102 +++++++ core/base/src/main/res/values-ru/strings.xml | 54 +- core/base/src/main/res/values/strings.xml | 2663 ++++++++--------- .../unitto/feature/settings/SettingsScreen.kt | 6 +- .../settings/formatting/FormattingScreen.kt | 20 +- .../feature/settings/themes/ThemesScreen.kt | 2 +- 9 files changed, 2924 insertions(+), 1391 deletions(-) create mode 100644 core/base/src/main/res/values-it/strings.xml diff --git a/core/base/src/main/res/values-de/strings.xml b/core/base/src/main/res/values-de/strings.xml index 401f5d77..0e12262b 100644 --- a/core/base/src/main/res/values-de/strings.xml +++ b/core/base/src/main/res/values-de/strings.xml @@ -377,7 +377,7 @@ aPa Femtopascal fPa - Picopascal + Pikopascal pPa Nanopascal nPa @@ -421,7 +421,7 @@ am/s^2 Femtometer pro Quadratsekunde fm/s^2 - Picometer pro Quadratsekunde + Pikometer pro Quadratsekunde pm/s^2 Nanometer pro Quadratsekunde mm/s^2 @@ -659,7 +659,9 @@ Farbthemen Präzision Trennzeichen - Ausgabeformate + + + Exponentielle Notation Einheitengruppen Falsche Wechselkurse Hinweis @@ -668,6 +670,7 @@ Datenschutz-Bestimmungen Richtlinien von Drittanbietern Diese App bewerten + Formatierung Zusätzliches @@ -681,16 +684,9 @@ Komma (42,069.12) Leerzeichen (42 069.12) - - Ergebnisformatierung - Wissenschaftliche Notation sieht wie 1E-21 aus - Standard - Wissenschaftliche Notation erlauben - Wissenschaftliche Notation erzwingen - App Aussehen - Automatisch + Automatisch Hell Dunkel Farbthema @@ -726,4 +722,381 @@ Einheitengruppe umordnen Einheitengruppe deaktivieren Versionsname + Über Unitto + Mehr über die App erfahren + Einheiten deaktivieren und anordnen + Cent + cent + + + Maxwell + Mx + Weber + Wb + Milliweber + mWb + Microweber + μWb + Kiloweber + kWb + Megaweber + MWb + Gigaweber + GWb + Flux + Quellcode anzeigen + Diese App übersetzen + Dem POEditor-Projekt beitreten und helfen + + + Binär + base2 + Ternär + base3 + Quartär + base4 + Quinär + base5 + Senär + base6 + Septenär + base7 + Oktal + base8 + Nonär + base9 + Dezimal + base10 + Undezimal + base11 + Duodezimal + base12 + Tridezimal + base13 + Tetradezimal + base14 + Pentadezimal + base15 + Hexadezimal + base16 + Basis + Vibrationen + Haptisches Feedback für Tastaturtasten + Millibar + mbar + Kilopascal + kPa + Mikrometer Quecksilber + μmHg + + + + Epochkonverter + + + Taschenrechner + + + y + m + Nautische Meile + M + Startbildschirm + Wählen Sie, welcher Bildschirm beim Starten der App angezeigt wird + + + Einheitenumrechner + Leeren + Verlauf leeren + Alle Ausdrücke aus dem Verlauf werden für immer gelöscht. Diese Aktion kann nicht rückgängig gemacht werden! + Kein Verlauf + Menü öffnen + Mikrogramm + µg + + + Attofarad + aF + Statfarad + stF + Farad + F + Exafarad + EF + Pikofarad + pF + Nanofarad + nF + Microfarad + µF + Millifarad + mF + Kilofarad + kF + Megafarad + MF + Gigafarad + GF + Petafarad + PF + + + Quetta + Q + Ronna + R + Yotta + Y + Zetta + Z + Exa + E + Peta + P + Tera + T + Giga + G + Mega + M + Kilo + k + Hekto + h + Deka + da + Basis + Basis + Dezi + d + Zenti + c + Milli + m + Micro + μ + Nano + n + Piko + P + Femto + f + Atto + a + Zepto + z + Yokto + y + Ronto + r + Quekto + q + + + Newton + N + Kilonewton + kN + + + Gramm-Kraft + gf + + + Kilogramm-Kraft + kgf + Tonnen-Kraft + tf + Millinewton + mN + Attonewton + aN + Dyn + dyn + Joule/Meter + J/m + Joule/Zentimeter + J/cm + Kilopfund-Kraft + kipf + Pfund-Kraft + lbf + Unzen-Kraft + ozf + Pond + p + + + Kilopond + kp + + + Newtonmeter + N*m + Newtonzentimeter + N*cm + Newtonmillimeter + N*mm + Kilonewtonmeter + kN*m + Dynmeter + dyn*m + + + Dynzentimeter + dyn*cm + + + Dynmillimeter + dyn*mm + + + Kilogramm-Kraft-Meter + kgf*m + + + Kilogramm-Kraft-Zentimeter + kgf*cm + + + Kilogramm-Kraft-Millimeter + kgf*mm + + + Gramm-Kraft Meter + gf*m + + + Gramm-Kraft Zentimeter + gf*cm + + + Gramm-Kraft Millimeter + gf*mm + + + Unzen-Kraft-Fuß + ozf*ft + + + Unze-Kraft-Zoll + ozf*in + Pfund-Kraft-Fuß + lbf*ft + + + Pfund-Kraft-Zoll + lbf*in + + + Liter/Stunde + L/h + Liter/Minute + L/m + Liter/Sekunde + L/s + Milliliter/Stunde + mL/h + Milliliter/Minute + mL/m + Milliliter/Sekunde + mL/s + Kubikmeter/Stunde + m3/h + Kubikmeter/Minute + m3/m + Kubikmeter/Sekunde + m3/s + Kubikmillimeter/Stunde + mm3/h + Kubikmillimeter/Minute + mm3/m + Kubikmillimeter/Sekunde + mm3/s + Kubikfuß/Stunde + ft3/h + Kubikfuß/Minute + ft3/m + Kubikfuß/Sekunde + ft3/s + Gallone/Stunde (U.S.) + gal/h + Gallone/Minute(U.S.) + gal/m + Gallone/Sekunde(U.S.) + gal/s + Gallone/Stunde (Imperial) + gal/h + Gallone/Minute(Imperial) + gal/m + Gallone/Sekunde(Imperial) + gal/s + + + Candela/Quadratmeter + cd/m^2 + Candela/Quadratzentimeter + cd/cm^2 + Candela/Quadratfuß + cd/ft^2 + Candela/Quadratzoll + cd/in^2 + Kilocandela/Quadratmeter + kcd + Stilb + sb + Lumen/Quadratmeter/Steradiant + lm/m^2/sr + Lumen/Quadratzentimeter/Steradian + lm/cm^2/sr + Lumen/Quadratfuß/Steradian + lm/ft^2/sr + Watt/Quadratzentimeter/Steradian + W/cm^2/sr + Nit + nt + Millinit + mnt + Lambert + L + Millilambert + mL + Fuß-Lambert + fL + Apostilb + asb + Blondel + blondel + Bril + bril + Skot + sk + Kapazität + Präfix + Kraft + Drehmoment + + + Fluss + Leuchtdichte + + + Zeit formatieren + Beispiel: 130 Minuten als 2h 10m anzeigen + Einheitenlistensortierung + Einheitenreihenfolge ändern + + + Benutzung + Alphabetisch + + + Skala (Abst.) + + + Skala (Aufst.) + Farbthema auswählen + Farbschema + Ausgewählte Farbe + Stil auswählen \ No newline at end of file diff --git a/core/base/src/main/res/values-en-rGB/strings.xml b/core/base/src/main/res/values-en-rGB/strings.xml index 835208c6..e7896fde 100644 --- a/core/base/src/main/res/values-en-rGB/strings.xml +++ b/core/base/src/main/res/values-en-rGB/strings.xml @@ -659,10 +659,8 @@ Themes Precision Separator - Output format + Exponential notation Unit groups - Vibrations - Haptic feedback when clicking keyboard buttons Wrong currency rates? Note Currency rates are updated daily. There\'s no real-time market monitoring in the app @@ -670,7 +668,7 @@ Privacy Policy Third party licenses Rate this app - Formatting + Formatting Additional @@ -685,15 +683,11 @@ Spaces (42 069.12) - Result value formatting - Engineering strings look like 1E-21 - Default - Allow engineering - Force engineering + Replace part of the number with E App look and feel - Auto + Auto Light Dark Colour theme @@ -787,4 +781,54 @@ Hexadecimal base16 Base + Vibrations + Haptic feedback when clicking keyboard buttons + Format time + Example: Show 130 minutes as 2h 10m + Units list sorting + Change units order + + + Usage + Alphabetical + Scale (Desc.) + Scale (Asc.) + Pick a theming mode + Colour scheme + Selected colour + Selected style + + + Precision and numbers appearance + Can\'t divide by 0 + Date difference + + + Select time + Start + End + Difference + + + Years + + + Months + + + Days + + + Hours + + + Minute + + + Next \ No newline at end of file diff --git a/core/base/src/main/res/values-fr/strings.xml b/core/base/src/main/res/values-fr/strings.xml index 3b030f1f..46736b3a 100644 --- a/core/base/src/main/res/values-fr/strings.xml +++ b/core/base/src/main/res/values-fr/strings.xml @@ -659,7 +659,9 @@ Thèmes Précision Séparateur - Format de sortie + + + Notation exponentielle Groupes d\'unités Note Les taux de change sont mis à jour quotidiennement. L\'application ne permet pas de suivre le marché en temps réel. @@ -667,7 +669,7 @@ Politique de confidentialité Licences tierces Évaluer l\'application - Formattage + Formattage Additional @@ -680,8 +682,7 @@ Période (42.069,12) Virgule (42,069.12) Espaces (42 069.12) - Défaut - Auto + Auto Clair Sombre AMOLED Noir diff --git a/core/base/src/main/res/values-it/strings.xml b/core/base/src/main/res/values-it/strings.xml new file mode 100644 index 00000000..3a59795a --- /dev/null +++ b/core/base/src/main/res/values-it/strings.xml @@ -0,0 +1,1102 @@ + + + + + "Attometro" + "am" + "Nanometro" + "nm" + "Micrometro" + "μm" + "Millimetro" + "mm" + "Centimetro" + "cm" + "Decimetro" + "dm" + "Metro" + "m" + "Chilometro" + "km" + "Miglio" + "Mi" + "Iarda" + "yd" + "Piede" + "ft" + "Pollice" + "in" + "Anno luce" + "al" + "Parsec" + "pc" + "Kiloparsec" + "kpc" + "Megaparsec" + "Mpc" + "Raggio equatoriale di Mercurio" + "R Mercurio" + "Raggio equatoriale di Venere" + "R Venere" + "Raggio equatoriale della Terra" + "R Terra" + "Raggio equatoriale di Marte" + "R Marte" + "Raggio equatoriale di Giove" + "R Giove" + "Raggio equatoriale di Saturno" + "R Saturno" + "Raggio equatoriale di Urano" + "R Urano" + "Raggio equatoriale di Nettuno" + "R Nettuno" + "Raggio equatoriale del Sole" + "R Sole" + + + "Massa elettrone" + "m e" + "Dalton" + "u" + "Milligrammo" + "mg" + "Grammo" + "g" + "Chilogrammo" + "hg" + "Tonnellata metrica" + "t" + "Tonnellata imperiale" + "t (UK)" + "Libbra" + "lbs" + "Oncia" + "oz" + "Carato" + "ct" + "Massa di Mercurio" + "M Mercurio" + "Massa di Venere" + "M Venere" + "Massa della Terra" + "M Terra" + "Massa di Marte" + "M Marte" + "Massa di Giove" + "M Giove" + "Massa di Saturno" + "M Saturno" + "Massa di Urano" + "M Urano" + "Massa di Nettuno" + "M Nettuno" + "Massa del Sole" + "M Sole" + + + "Celsius" + "Fahrenheit" + "Kelvin" + + + "Millimetro all'ora" + "mm/h" + "Millimetro al minuto" + "mm/m" + "Millimetro al secondo" + "mm/s" + "Centimetro all'ora" + "cm/h" + "Centimetro al minuto" + "cm/m" + "Centimetro al secondo" + "cm/s" + "Metro all'ora" + "m/h" + "Metro al minuto" + "m/m" + "Metro al secondo" + "m/s" + "Chilometro all'ora" + "km/h" + "Chilometro al minuto" + "km/m" + "Chilometro al secondo" + "km/s" + "Piede all'ora" + "ft/h" + "Piede al minuto" + "ft/m" + "Piede al secondo" + "ft/s" + "Iarda all'ora" + "yd/h" + "Iarda al minuto" + "yd/m" + "Iarda al secondo" + "yd/s" + "Miglia all'ora" + "mi/h" + "Miglia al minuto" + "mi/m" + "Miglia al secondo" + "mi/s" + "Nodi" + "kt" + "Velocità della luce nel vuoto" + "Prima velocità cosmica" + "Seconda velocità cosmica" + "Terza velocità cosmica" + "Velocità orbitale della Terra" + "Mach" + "Mach (SI)" + + + "Bit" + "b" + "Kibibit" + "Kib" + "Kilobit" + "Kb" + "Megabit" + "Mb" + "Mebibit" + "Mib" + "Gigabit" + "Gb" + "Terabit" + "Tb" + "Petabit" + "Pb" + "Exabit" + "Eb" + "Byte" + "B" + "Kibibyte" + "KiB" + "Kilobyte" + "KB" + "Megabyte" + "MB" + "Mebibyte" + "MiB" + "Gigabyte" + "GB" + "Terabyte" + "TB" + "Petabyte" + "PB" + "Exabyte" + "EB" + + + "Bit al secondo" + "b/s" + "Kibibit al secondo" + "Kib/s" + "Kilobit al secondo" + "Kb/s" + "Megabit al secondo" + "Mb/s" + "Mebibit al secondo" + "Mib/s" + "Gigabit al secondo" + "Gb/s" + "Terabit al secondo" + "Tb/s" + "Petabit al secondo" + "Pb/s" + "Exabit al secondo" + "Eb/s" + "Byte al secondo" + "B/s" + "Kibibyte al secondo" + "KiB/s" + "Kilobyte al secondo" + "KB/s" + "Megabyte al secondo" + "MB/s" + "Mebibyte al secondo" + "MiB/s" + "Gigabyte al secondo" + "GB/s" + "Terabyte al secondo" + "TB/s" + "Petabyte al secondo" + "PB/s" + "Exabyte al secondo" + "EB/s" + + + "Attolitro" + "aL" + "Millilitro" + "mL" + "Litro" + "L" + "Gallone USA" + "gal (US)" + "Quarto US" + "qt (US)" + "Pinta US" + "pt (US)" + "Coppa US" + "cup (US)" + "Oncia liquida US" + "fl oz (US)" + "Cucchiaio US" + "cucchiaio (US)" + "Cucchiaino US" + "cucchiaino (US)" + "Gallone imperiale" + "gal (UK)" + "Quarto imperiale" + "qt (UK)" + "Pinta imperiale" + "pt (UK)" + "Coppa imperiale" + "tazza (UK)" + "Oncia liquida imperiale" + "fl oz (UK)" + "Cucchiaio imperiale" + "Cucchiaino (UK)" + "Cucchiaino imperiale" + "Cucchiaino (UK)" + "Millimetro cubo" + "mm^3" + "Centimetro cubo" + "cm^3" + "Metro cubo" + "m^3" + "Chilometro cubo" + "km^3" + + + "Attosecondo" + "as" + "Nanosecondo" + "ns" + "Microsecondo" + "µs" + "Millisecondo" + "ms" + "Jiffy" + "j" + "Secondo" + "s" + "Minuto" + "m" + "Ora" + "h" + "Giorno" + "g" + "Settimana" + "s" + + + "Sezione d'urto dell'elettrone" + "ecs" + "Acro" + "ac" + "Ettaro" + "ha" + "Piede quadrato" + "ft^2" + "Miglio quadrato" + "mi^2" + "Iarda quadrata" + "yd^2" + "Pollice quadrato" + "in^2" + "Micrometro quadrato" + "µm^2" + "Millimetro quadrato" + "mm^2" + "Centimetro quadrato" + "cm^2" + "Decimetro quadrato" + "dm^2" + "Metro quadrato" + "m^2" + "Chilometro quadrato" + "km^2" + + + "Elettronvolt" + "eV" + "Attojoule" + "aJ" + "Cavallo potenza" + "hp" + "Joule" + "J" + "Kilojoule" + "kJ" + "Megajoule" + "MJ" + "Gigajoule" + "GJ" + "Tonnellata di TNT" + "t" + "Chilotone di TNT" + "kt" + "Megatone di TNT" + "Mt" + "Gigatone di TNT" + "Gt" + "Calorie (th)" + "cal" + "Kilocalorie (th)" + "kcal" + + + "Attowatt" + "aW" + "Watt" + "W" + "Kilowatt" + "kW" + "Megawatt" + "MW" + "Cavallo di potenza" + "hp" + + + "Secondo d'angolo" + "Minuto" + "Grado" + "Radiante" + "rad" + "Sestante" + "sxt" + "Giro" + "tr" + + + "Attopascal" + "aPa" + "Femtopascal" + "fPa" + "Picopascal" + "pPa" + "Nanopascal" + "nPa" + "Micropascal" + "µPa" + "Millipascal" + "mPa" + "Centipascal" + "cPa" + "Decipascal" + "dPa" + "Pascal" + "Pa" + "Decapascal" + "daPa" + "Ettopascal" + "hPa" + "Bar" + "bar" + "Megapascal" + "MPa" + "Gigapascal" + "GPA" + "Terapascal" + "TPa" + "Petapascal" + "PPa" + "Esapascal" + "EPa" + "Libbra/pollice quadrato" + "Kilolibbra/pollice quadrato" + "Atmosfera standard" + "atm" + "Torr" + "torr" + "Millimetro di mercurio" + "mmHg" + + + "Attometro/secondo quadrato" + "am/s^2" + "Femtometro/secondo quadrato" + "fm/s^2" + "Picometro/secondo quadrato" + "pm/s^2" + "Nanometro/secondo quadrato" + "nm/s^2" + "Micrometro/secondo quadrato" + "µm/s^2" + "Millimetro/secondo quadrato" + "mm/s^2" + "Centimetro/secondo quadrato" + "cm/s^2" + "Decimetro/secondo quadrato" + "dm/s^2" + "Metro/secondo quadrato" + "m/s^2" + "Kilometro/secondo quadrato" + "km/s^2" + "Decametro/secondo quadrato" + "dam/s^2" + "Ettometro/secondo quadrato" + "hm/s^2" + "Gallone" + "Gal" + "Gravità superficie di Mercurio" + "Mercurio g" + "Gravità superficie di Venere" + "Venere g" + "Gravità superficie della Terra" + "Terra g" + "Gravità superficie di Marte" + "Marte g" + "Gravità superficie di Giove" + "Giove g" + "Gravità superficie di Saturno" + "Saturno g" + "Gravità superficie di Urano" + "Urano g" + "Gravità superficie di Nettuno" + "Nettuno g" + "Gravità superficie del Sole" + "Sole g" + "Cardano" + "Dirham Emirati Arabi" + "Afghani" + "Lek albanese" + "Dram armeno" + "Fiorino delle Antille Olandesi" + "Kwanza angolano" + "Peso argentino" + "Dollaro australiano" + "Fiorino di Aruba" + "Manat dell'Azerbaigian" + "Marco bosniaco-ungherese convertibile" + "Dollaro delle Barbados" + "Taka bengalese" + "Lev bulgaro" + "Dinaro del Bahrain" + "Franco del Burundi" + "Dollaro delle Bermuda" + "Dollaro del Brunei" + "Boliviano" + "Real brasiliano" + "Dollaro delle Bahamas" + "Ngultrum del Bhutan" + "Pula del Botswana" + "Rublo bielorusso" + "Rublo bielorusso" + "Dollaro belizeano" + "Dollaro canadese" + "Franco congolese" + "Franco svizzero" + "Chilean Unit of Account (UF)" + "Peso cileno" + "Yuan cinese" + "Peso colombiano" + "Colón costaricano" + "Peso cubano" + "Peso cubano" + "Escudo di Capo Verde" + "Corona ceca" + "Dai" + "Franco del Gibuti" + "Corona danese" + "Peso dominicano" + "Dinaro algerino" + "Lira egiziana" + "Nafka eritra" + "Birr etiope" + "Euro" + "Dollaro delle Fiji" + "Sterlina delle Falkland" + "Sterlina" + "Lari georgese" + "Cedi ghanese" + "Sterlina di Gibilterra" + "Dalasi gambiano" + "Franco guineano" + "Quetzal del Guatemala" + "Dollaro guyanese" + "Dollaro di Hong Kong" + "Lempira di Honduras" + "Kuna croata" + "Gourde di Haiti" + "Forint ungherese" + "Rupia indonesiana" + "Shekel" + "Rupia indiana" + "Dinaro iracheno" + "Rial iraniano" + "Corona islandese" + "Sterlina di Jersey" + "Dollaro giamaicano" + "Dinaro giordano" + "Yen giapponese" + "Scellino keniota" + "Som del Kirghizistan" + "Riel cambogiano" + "Franco delle Isole Comore" + "Won nord coreano" + "Won sud coreano" + "Dinaro del Kuwait" + "Dollaro Isole Cayman" + "Tenge kazako" + "Kip di Laos" + "Lira libanese" + "Rupia srilankese" + "Dollaro liberiano" + "Loti del Lesoto" + "Lita lituana" + "Lat lettone" + "Dinaro libico" + "Dirham marocchino" + "Leu moldavo" + "Ariary malgascio" + "Dinaro macedone" + "Kyat" + "Tugrik" + "Pataca" + "Ouguiya mauritana" + "Rupia mauritana" + "Rufiyaa maldiviana" + "Kwacha malawiano" + "Peso messicano" + "Ringgit malese" + "Metical mozambicano" + "Dollaro namibiano" + "Naira nigeriana" + "Córdoba nicaraguense" + "Corona norvegeese" + "Rupia nepalese" + "Dollaro neozelandese" + "Riyal dell'Oman" + "Balboa panamense" + "Sol" + "Kina papuana" + "Peso filippino" + "Rupia pakistana" + "Złoty polacco" + "Guaraní paraguaiano" + "Rial qatariota" + "Leu rumeno" + "Dinaro serbo" + "Rublo russo" + "Franco ruandese" + "Riyal saudita" + "Dollaro delle Salomone" + "Rupia delle Seychelles" + "Dinaro sudanese" + "Corona svedese" + "Dollaro di Singapore" + "Shiba Inu" + "Sterlina di Sant'Elena" + "Leone del Sierra Leone" + "Scellinio somalo" + "Dollaro del Suriname" + "Dobra di São Tomé e Príncipe (pre-2018)" + "Colon salvadoregno" + "Lira siriana" + "Lilangeni" + "Baht tailandese" + "Theta" + "Somoni del Tagikistan" + "Manat turkmeno" + "Dinaro tunisino" + "Paʻanga tongano" + "Lira turca" + "Dollaro di Trinidad & Tobago" + "Dollaro taiwanese" + "Scellino della Tanzania" + "Hryvnia ucraino" + "Scellino ugandese" + "Universe" + "Dollaro statunitense" + "USD Coin" + "Peso uruguaiano" + "Som uzbeco" + "Bolívar venezuelano" + "Dong vietnamita" + "Vatu di Vanuatu" + "Wrapped Bitcoin" + "Tala samoano" + "Franco CFA" + "Oncia d'argento" + "Dollaro dei Caraibi Orientali" + "Diritti speciali di prelievo" + "West African CFA franc" + "Franco CFP" + "Rial yemenita" + "Rand sudafricano" + "Kwacha" + "Kwacha zambiano" + "Dollaro dello Zimbabwe" + + + "Lunghezza" + "Tempo" + "Volume" + "Area" + "Temperatura" + "Velocità" + "Massa" + "Data" + "Energia" + "Potenza" + "Angolo" + "Traferimenti dati" + "Pressione" + "Accelerazione" + "Valuta" + + + "Converti da" + "Converti in" + "Impostazioni" + + + "Temi" + "Precisione" + "Separatore" + + + "Notazione esponenziale" + "Gruppi di unità" + "Tassi di cambio sbagliati?" + "Note" + "I tassi di cambio sono aggiornati quotidianamente. Non c'è alcun monitoraggio in tempo reale del mercato nell'app" + "Termini e condizioni" + "Politica sulla riservatezza" + "Licenze terze parti" + "Valuta questa app" + "Formattazione" + "Aggiuntive" + + + "Numero di posti decimali" + "Valori convertiti potrebbero avere una precisione maggiore di quella preferita." + "1 000 (Max)" + + + "Simbolo separatore gruppo" + "Punto (42.069,12)" + "Virgola (42,069.12)" + "Spazio (42 069.12)" + + + "Replace part of the number with E" + + + "Aspetto dell'app" + "Auto" + "Chiaro" + "Scuro" + "Colore tema" + "Scuro AMOLED" + "Usa sfondo nero per temi scuri" + "Colori dinamici" + "Usa colori dal tuo sfondo" + + + "Caricamento…" + "Errore" + "Copiato %1$s!" + "Cancella" + "Cerca unità" + "Nessun risultato trovato" + "Apri impostazioni" + "Assicurati che non ci siano errori, prova filtri diversi o controlla i gruppi di unità disabilitati." + "Ciao!" + "Abilitato" + "Disabilitato" + + + "Naviga verso l'alto" + "Filtri selezionati" + "Apri impostazioni" + "Inverti unità" + "Pulsante cerca" + "Svuota input" + "Aggiungi o rimuovi unità dai preferiti" + "Svuota risultato ricerca" + "Apri o chiudi menù a discesa" + "Abilita gruppo unità" + "Riordina gruppo unità" + "Disabilita gruppi di unità" + "Nome versione" + "Riguardo a Unitto" + "Impara riguardo all'app" + "Disabilita e riordina unità" + "Centesimo" + "cent" + + + "Maxwell" + "Mx" + "Weber" + "Wb" + "Milliweber" + "mWb" + "Microweber" + "μWb" + "Kiloweber" + "kWb" + "Megaweber" + "MWb" + "Gigaweber" + "GWb" + "Flusso" + "Vedi codice sorgente" + "Traduci questa app" + "Unisciti al progetto POEditor per aiutare" + + + "Binario" + "base2" + "Ternario" + "base3" + "Quaternario" + "base4" + "Quinario" + "base5" + "Senario" + "base6" + "Settenario" + "base7" + "Ottale" + "base8" + "Nonario" + "base9" + "Decimale" + "base10" + "Undecimale" + "base11" + "Duodecimale" + "base12" + "Tridecimale" + "base13" + "Tetradecimale" + "base14" + "Pentadecimale" + "base15" + "Esadecimale" + "base16" + "Base" + "Vibrazioni" + "Feedback tattile quando si fa clic sui pulsanti della tastiera" + "Millibar" + "mbar" + "Kilopascal" + "kPa" + "Micron di mercurio" + "μmHg" + + + "Convertitore Epoch" + + + "Calcolatrice" + + + "a" + "m" + "Miglio nautico" + "M" + "Schermata iniziale" + "Scegli quale schermata è mostrata quando avvi l'app" + + + "Convertitore di unità" + "Pulisci" + "Pulisci cronologia" + "Tutti risultati dalla cronologia saranno eliminati per sempre. Questa azione non può essere annullata!" + "Nessuna cronologia" + "Apri menù" + "Microgram" + "µg" + + + "Attofarad" + "aF" + "Statfarad" + "stF" + "Farad" + "F" + "Exafarad" + "EF" + "Picofarad" + "pF" + "Nanofarad" + "nF" + "Microfarad" + "µF" + "Millifarad" + "mF" + "Kilofarad" + "kF" + "Megafarad" + "MF" + "Gigafarad" + "GF" + "Petafarad" + "PF" + + + "Quetta" + "Q" + "Ronna" + "R" + "Yotta" + "Y" + "Zetta" + "Z" + "Esa" + "E" + "Peta" + "P" + "Tera" + "T" + "Giga" + "G" + "Mega" + "M" + "Kilo" + "k" + "Etto" + "h" + "Deci" + "da" + "Base" + "Base" + "Deci" + "d" + "Centi" + "c" + "Milli" + "m" + "Micro" + "μ" + "Nano" + "n" + "Pico" + "p" + "Femto" + "f" + "Atto" + "a" + "Zepto" + "z" + "Yocto" + "y" + "Ronto" + "r" + "Quecto" + "q" + + + "Newton" + "N" + "Kilonewton" + "kN" + "Grammo-forza" + "gf" + "Kilogrammo-forza" + "kgf" + "Tonnellata-forza" + "tf" + "Millinewton" + "mN" + "Attonewton" + "aN" + "Dyne" + "dyn" + "Joule/metro" + "J/m" + "Joule/centimetro" + "J/cm" + "Kilolibbra-forza" + "kipf" + "Libbra-forza" + "lbf" + "Oncia-forza" + "ozf" + "Pond" + "p" + "Kilopond" + "kp" + + + "Newton metro" + "N*m" + "Newton centimetro" + "N*cm" + "Newton millimetro" + "N*mm" + "Kilonewton metro" + "kN*m" + "Dyne metro" + "dyn*m" + "Dyne centimetro" + "dyn*cm" + "Dyne millimetro" + "dyn*mm" + "Kilogrammo-forza metro" + "kgf*m" + "Kilogrammo-forza centimetro" + "kgf*cm" + "Kilogrammo-forza millimetro" + "kgf*mm" + "Grammo-forza metro" + "gf*m" + "Grammo-forza centimetro" + "gf*cm" + "Grammo-forza millimetro" + "gf*mm" + "Oncia-forza piede" + "ozf*ft" + "Oncia-forza pollice" + "ozf*in" + "Libbra-forza piede" + "lbf*ft" + "Libbra-forza pollice" + "lbf*in" + + + "Litro/ora" + "L/h" + "Litro/minuto" + "L/m" + "Litro/secondo" + "L/s" + "Millilitro/ora" + "mL/h" + "Millilitro/minuto" + "mL/m" + "Millilitro/secondo" + "mL/s" + "Metro cubo/ora" + "m3/h" + "Metro cubo/minuto" + "m3/m" + "Metro cubo/secondo" + "m3/s" + "Millimetro cubo/ora" + "mm3/h" + "Millimetro cubo/minuto" + "mm3/m" + "Millimetro cubo/secondo" + "mm3/s" + "Piede cubo/ora" + "ft3/h" + "Piede cubo/minuto" + "ft3/m" + "Piede cubo/secondo" + "ft3/s" + "Gallone/ora (U.S.)" + "gal/h" + "Gallone/minuto (U.S.)" + "gal/m" + "Gallone/secondo (U.S.)" + "gal/s" + "Gallone/ora (Imperiale)" + "gal/h" + "Gallone/minuto (Imperiale)" + "gal/m" + "Gallone/secondo (Imperiale)" + "gal/s" + + + "Candela/metro quadrato" + "cd/m^2" + "Candela/centimetro quadrato" + "cd/cm^2" + "Candela/piede quadrato" + "cd/ft^2" + "Candela/pollice quadrato" + "cd/in^2" + "Kilocandela/metro quadrato" + "kcd" + "Stilb" + "sb" + "Lumen/metro quadrato/steradian" + "lm/m^2/sr" + "Lumen/centimetro quadrato/steradian" + "lm/cm^2/sr" + "Lumen/piede quadrato/steradian" + "lm/ft^2/sr" + "Watt/centimetro quadrato/steradian" + "W/cm^2/sr" + "Nit" + "nt" + "Millinit" + "mnt" + "Lambert" + "L" + "Millilambert" + "mL" + "Piede-lambert" + "fL" + "Apostilb" + "asb" + "Blondel" + "blondel" + "Bril" + "bril" + "Skot" + "sk" + "Capacità" + "Prefisso" + "Forza" + "Coppia" + "Flusso" + "Luminanza" + "Formato tempo" + "Esempio: Mostra 130 minuti come 2h 10m" + "Ordine lista unità" + "Cambia ordine unità" + + + "Utilizzo" + "Alfabetico" + "Scala (Decr.)" + "Scala (Asc.)" + "Scegli una modalità tematica" + "Schema colore" + "Colore selezionato" + "Stile selezionato" + + + "Precision and numbers appearance" + "Can't divide by 0" + "Date difference" + + + "Select time" + "Start" + "End" + "Difference" + + + "Years" + + + "Months" + + + "Days" + + + "Hours" + + + "Minute" + + + "Next" + \ No newline at end of file diff --git a/core/base/src/main/res/values-ru/strings.xml b/core/base/src/main/res/values-ru/strings.xml index 384a77c4..c63e1eeb 100644 --- a/core/base/src/main/res/values-ru/strings.xml +++ b/core/base/src/main/res/values-ru/strings.xml @@ -659,7 +659,7 @@ Темы Точность Разделитель - Формат вывода + Экспоненциальная нотация Группы величин Неправильные курсы валют? Внимание @@ -668,7 +668,7 @@ Политика конфиденциальности Лицензии третьих лиц Оценить приложение - Форматирование + Форматирование Дополнительное @@ -683,20 +683,16 @@ Пробел (42 069.12) - Формат результата перевода - Инженерный формат выглядит как 1E-21 - По умолчанию - Разрешить инженерный - Преимущественно инженерный + Замените часть числа на E Внешний вид приложения - Автоматическая + Авто Светлая - Темная + Тёмная Цветовая тема - Темная AMOLED - Использовать черный фон в темных темах + Чёрная AMOLED + Использовать чёрный фон в тёмной теме Динамичные цвета Использовать цвета обоев @@ -1048,7 +1044,7 @@ Скот ск Емкость - Префиск + Префикс Сила Момент Течение @@ -1067,4 +1063,38 @@ Цветовая схема Выбранный цвет Выбранный стиль + + + Точность и представление чисел + Нельзя делить на 0 + Разница между датами + + + Выберите время + Начало + Конец + Разница + + + Лет + + + Месяцев + + + Дней + + + Часов + + + Минут + + + Далее \ No newline at end of file diff --git a/core/base/src/main/res/values/strings.xml b/core/base/src/main/res/values/strings.xml index e5911fb2..16714fc9 100644 --- a/core/base/src/main/res/values/strings.xml +++ b/core/base/src/main/res/values/strings.xml @@ -1,1381 +1,1364 @@ - - + - Unitto + "Unitto" - - Attometer - am - Nanometer - nm - Micrometer - μm - Millimeter - mm - Centimeter - cm - Decimeter - dm - Meter - m - Kilometer - km - Mile - Mi - Nautical mile - M - Yard - yd - Foot - ft - Inch - in - Light year - ly - Parsec - pc - Kiloparsec - kpc - Megaparsec - Mpc - Mercury equatorial radius - Mercury R - Venus equatorial radius - Venus R - Earth equatorial radius - Earth R - Mars equatorial radius - Mars R - Jupiter equatorial radius - Jupiter R - Saturn equatorial radius - Saturn R - Uranus equatorial radius - Uranus R - Neptune equatorial radius - Neptune R - Sun equatorial radius - Sun R + + "Attometer" + "am" + "Nanometer" + "nm" + "Micrometer" + "μm" + "Millimeter" + "mm" + "Centimeter" + "cm" + "Decimeter" + "dm" + "Meter" + "m" + "Kilometer" + "km" + "Mile" + "Mi" + "Yard" + "yd" + "Foot" + "ft" + "Inch" + "in" + "Light year" + "ly" + "Parsec" + "pc" + "Kiloparsec" + "kpc" + "Megaparsec" + "Mpc" + "Mercury equatorial radius" + "Mercury R" + "Venus equatorial radius" + "Venus R" + "Earth equatorial radius" + "Earth R" + "Mars equatorial radius" + "Mars R" + "Jupiter equatorial radius" + "Jupiter R" + "Saturn equatorial radius" + "Saturn R" + "Uranus equatorial radius" + "Uranus R" + "Neptune equatorial radius" + "Neptune R" + "Sun equatorial radius" + "Sun R" - - Electron mass - me - Dalton - u - Microgram - µg - Milligram - mg - Gram - g - Kilogram - kg - Metric ton - t - Imperial ton - t (UK) - Pound - lbs - Ounce - oz - Carat - ct - Mercury mass - Mercury M - Venus mass - Venus M - Earth mass - Earth M - Mars mass - Mars M - Jupiter mass - Jupiter M - Saturn mass - Saturn M - Uranus mass - Uranus M - Neptune mass - Neptune M - Sun mass - Sun M + + "Electron mass" + "me" + "Dalton" + "u" + "Milligram" + "mg" + "Gram" + "g" + "Kilogram" + "kg" + "Metric ton" + "t" + "Imperial ton" + "t (UK)" + "Pound" + "lbs" + "Ounce" + "oz" + "Carat" + "ct" + "Mercury mass" + "Mercury M" + "Venus mass" + "Venus M" + "Earth mass" + "Earth M" + "Mars mass" + "Mars M" + "Jupiter mass" + "Jupiter M" + "Saturn mass" + "Saturn M" + "Uranus mass" + "Uranus M" + "Neptune mass" + "Neptune M" + "Sun mass" + "Sun M" - - Celsius - °C - Fahrenheit - °F - Kelvin - K + + "Celsius" + "°C" + "Fahrenheit" + "°F" + "Kelvin" + "K" - - Millimeter/hour - mm/h - Millimeter/minute - mm/m - Millimeter/second - mm/s - Centimeter/hour - cm/h - Centimeter/minute - cm/m - Centimeter/second - cm/s - Meter/hour - m/h - Meter/minute - m/m - Meter/second - m/s - Kilometer/hour - km/h - Kilometer/minute - km/m - Kilometer/second - km/s - Foot/hour - ft/h - Foot/minute - ft/m - Foot/second - ft/s - Yard/hour - yd/h - Yard/minute - yd/m - Yard/second - yd/s - Mile/hour - mi/h - Mile/minute - mi/m - Mile/second - mi/s - Knot - kt - Speed of light in vacuum - c - First Cosmic Velocity - v1 - Second Cosmic Velocity - v2 - Third Cosmic Velocity - v3 - Earth\'s orbital speed - ve - Mach - M - Mach (SI) - M + + "Millimeter/hour" + "mm/h" + "Millimeter/minute" + "mm/m" + "Millimeter/second" + "mm/s" + "Centimeter/hour" + "cm/h" + "Centimeter/minute" + "cm/m" + "Centimeter/second" + "cm/s" + "Meter/hour" + "m/h" + "Meter/minute" + "m/m" + "Meter/second" + "m/s" + "Kilometer/hour" + "km/h" + "Kilometer/minute" + "km/m" + "Kilometer/second" + "km/s" + "Foot/hour" + "ft/h" + "Foot/minute" + "ft/m" + "Foot/second" + "ft/s" + "Yard/hour" + "yd/h" + "Yard/minute" + "yd/m" + "Yard/second" + "yd/s" + "Mile/hour" + "mi/h" + "Mile/minute" + "mi/m" + "Mile/second" + "mi/s" + "Knot" + "kt" + "Speed of light in vacuum" + "c" + "First Cosmic Velocity" + "v1" + "Second Cosmic Velocity" + "v2" + "Third Cosmic Velocity" + "v3" + "Earth's orbital speed" + "ve" + "Mach" + "M" + "Mach (SI)" + "M" - - Bit - b - Kibibit - Kib - Kilobit - Kb - Megabit - Mb - Mebibit - Mib - Gigabit - Gb - Terabit - Tb - Petabit - Pb - Exabit - Eb - Byte - B - Kibibyte - KiB - Kilobyte - KB - Megabyte - MB - Mebibyte - MiB - Gigabyte - GB - Terabyte - TB - Petabyte - PB - Exabyte - EB + + "Bit" + "b" + "Kibibit" + "Kib" + "Kilobit" + "Kb" + "Megabit" + "Mb" + "Mebibit" + "Mib" + "Gigabit" + "Gb" + "Terabit" + "Tb" + "Petabit" + "Pb" + "Exabit" + "Eb" + "Byte" + "B" + "Kibibyte" + "KiB" + "Kilobyte" + "KB" + "Megabyte" + "MB" + "Mebibyte" + "MiB" + "Gigabyte" + "GB" + "Terabyte" + "TB" + "Petabyte" + "PB" + "Exabyte" + "EB" - - Bit/second - b/s - Kibibit/second - Kib/s - Kilobit/second - Kb/s - Megabit/second - Mb/s - Mebibit/second - Mib/s - Gigabit/second - Gb/s - Terabit/second - Tb/s - Petabit/second - Pb/s - Exabit/second - Eb/s - Byte/second - B/s - Kibibyte/second - KiB/s - Kilobyte/second - KB/s - Megabyte/second - MB/s - Mebibyte/second - MiB/s - Gigabyte/second - GB/s - Terabyte/second - TB/s - Petabyte/second - PB/s - Exabyte/second - EB/s + + "Bit/second" + "b/s" + "Kibibit/second" + "Kib/s" + "Kilobit/second" + "Kb/s" + "Megabit/second" + "Mb/s" + "Mebibit/second" + "Mib/s" + "Gigabit/second" + "Gb/s" + "Terabit/second" + "Tb/s" + "Petabit/second" + "Pb/s" + "Exabit/second" + "Eb/s" + "Byte/second" + "B/s" + "Kibibyte/second" + "KiB/s" + "Kilobyte/second" + "KB/s" + "Megabyte/second" + "MB/s" + "Mebibyte/second" + "MiB/s" + "Gigabyte/second" + "GB/s" + "Terabyte/second" + "TB/s" + "Petabyte/second" + "PB/s" + "Exabyte/second" + "EB/s" - - Attoliter - aL - Milliliter - mL - Liter - L - US liquid gallon - gal (US) - US liquid quart - qt (US) - US liquid pint - pt (US) - US legal cup - cup (US) - US fluid ounce - fl oz (US) - US tablespoon - tablespoon (US) - US teaspoon - teaspoon (US) - Imperial gallon - gal (UK) - Imperial quart - qt (UK) - Imperial pint - pt (UK) - Imperial cup - cup (UK) - Imperial fluid ounce - fl oz (UK) - Imperial tablespoon - tablespoon (UK) - Imperial teaspoon - teaspoon (UK) - Cubic millimeter - mm^3 - Cubic centimeter - cm^3 - Cubic meter - m^3 - Cubic kilometer - km^3 + + "Attoliter" + "aL" + "Milliliter" + "mL" + "Liter" + "L" + "US liquid gallon" + "gal (US)" + "US liquid quart" + "qt (US)" + "US liquid pint" + "pt (US)" + "US legal cup" + "cup (US)" + "US fluid ounce" + "fl oz (US)" + "US tablespoon" + "tablespoon (US)" + "US teaspoon" + "teaspoon (US)" + "Imperial gallon" + "gal (UK)" + "Imperial quart" + "qt (UK)" + "Imperial pint" + "pt (UK)" + "Imperial cup" + "cup (UK)" + "Imperial fluid ounce" + "fl oz (UK)" + "Imperial tablespoon" + "tablespoon (UK)" + "Imperial teaspoon" + "teaspoon (UK)" + "Cubic millimeter" + "mm^3" + "Cubic centimeter" + "cm^3" + "Cubic meter" + "m^3" + "Cubic kilometer" + "km^3" - - Attosecond - as - Nanosecond - ns - Microsecond - µs - Millisecond - ms - Jiffy - j - Second - s - Minute - m - Hour - h - Day - d - Week - w + + "Attosecond" + "as" + "Nanosecond" + "ns" + "Microsecond" + "µs" + "Millisecond" + "ms" + "Jiffy" + "j" + "Second" + "s" + "Minute" + "m" + "Hour" + "h" + "Day" + "d" + "Week" + "w" - - Electron cross section - ecs - Cent - cent - Acre - ac - Hectare - ha - Square foot - ft^2 - Square mile - mi^2 - Square yard - yd^2 - Square inch - in^2 - Square micrometer - µm^2 - Square millimeter - mm^2 - Square centimeter - cm^2 - Square decimeter - dm^2 - Square meter - m^2 - Square kilometer - km^2 + + "Electron cross section" + "ecs" + "Acre" + "ac" + "Hectare" + "ha" + "Square foot" + "ft^2" + "Square mile" + "mi^2" + "Square yard" + "yd^2" + "Square inch" + "in^2" + "Square micrometer" + "µm^2" + "Square millimeter" + "mm^2" + "Square centimeter" + "cm^2" + "Square decimeter" + "dm^2" + "Square meter" + "m^2" + "Square kilometer" + "km^2" - - Electron volt - eV - Attojoule - aJ - Horse power - hp - Joule - J - Kilojoule - kJ - Megajoule - MJ - Gigajoule - GJ - Ton of TNT - t - Kiloton of TNT - kt - Megaton of TNT - Mt - Gigaton of TNT - Gt - Calorie (th) - cal - Kilocalorie (th) - kcal + + "Electron volt" + "eV" + "Attojoule" + "aJ" + "Horse power" + "hp" + "Joule" + "J" + "Kilojoule" + "kJ" + "Megajoule" + "MJ" + "Gigajoule" + "GJ" + "Ton of TNT" + "t" + "Kiloton of TNT" + "kt" + "Megaton of TNT" + "Mt" + "Gigaton of TNT" + "Gt" + "Calorie (th)" + "cal" + "Kilocalorie (th)" + "kcal" - - Attowatt - aW - Watt - W - Kilowatt - kW - Megawatt - MG - Horsepower - hp + + "Attowatt" + "aW" + "Watt" + "W" + "Kilowatt" + "kW" + "Megawatt" + "MG" + "Horsepower" + "hp" - - Second - \" - Minute - \' - Degree - ° - Radian - rad - Sextant - sxt - Turn - tr + + "Second" + "\"" + "Minute" + "'" + "Degree" + "°" + "Radian" + "rad" + "Sextant" + "sxt" + "Turn" + "tr" - - Attopascal - aPa - Femtopascal - fPa - Picopascal - pPa - Nanopascal - nPa - Micropascal - µPa - Millipascal - mPa - Centipascal - cPa - Decipascal - dPa - Pascal - Pa - Dekapascal - daPa - Hectopascal - hPa - Millibar - mbar - Bar - bar - Kilopascal - kPa - Megapascal - MPa - Gigapascal - GPA - Terapascal - TPa - Petapascal - PPa - Exapascal - EPa - Pound/square inch - psi - Kilopound/square inch - ksi - Standard atmosphere - atm - Torr - torr - Micron of mercury - μmHg - Millimeter of mercury - mm Hg + + "Attopascal" + "aPa" + "Femtopascal" + "fPa" + "Picopascal" + "pPa" + "Nanopascal" + "nPa" + "Micropascal" + "µPa" + "Millipascal" + "mPa" + "Centipascal" + "cPa" + "Decipascal" + "dPa" + "Pascal" + "Pa" + "Dekapascal" + "daPa" + "Hectopascal" + "hPa" + "Bar" + "bar" + "Megapascal" + "MPa" + "Gigapascal" + "GPA" + "Terapascal" + "TPa" + "Petapascal" + "PPa" + "Exapascal" + "EPa" + "Pound/square inch" + "psi" + "Kilopound/square inch" + "ksi" + "Standard atmosphere" + "atm" + "Torr" + "torr" + "Millimeter of mercury" + "mm Hg" - - Attometer/square second - am/s^2 - Femtometer/square second - fm/s^2 - Picometer/square second - pm/s^2 - Nanometer/square second - nm/s^2 - Micrometer/square second - µm/s^2 - Millimeter/square second - mm/s^2 - Centimeter/square second - cm/s^2 - Decimeter/square second - dm/s^2 - Meter/square second - m/s^2 - Kilometer/square second - km/s^2 - Dekameter/square second - dam/s^2 - Hectometer/square second - hm/s^2 - Gal - Gal - Mercury surface gravity - Mercury g - Venus surface gravity - Venus g - Earth surface gravity - Earth g - Mars surface gravity - Mars g - Jupiter surface gravity - Jupiter g - Saturn surface gravity - Saturn g - Uranus surface gravity - Uranus g - Neptune surface gravity - Neptune g - Sun surface gravity - Sun g + + "Attometer/square second" + "am/s^2" + "Femtometer/square second" + "fm/s^2" + "Picometer/square second" + "pm/s^2" + "Nanometer/square second" + "nm/s^2" + "Micrometer/square second" + "µm/s^2" + "Millimeter/square second" + "mm/s^2" + "Centimeter/square second" + "cm/s^2" + "Decimeter/square second" + "dm/s^2" + "Meter/square second" + "m/s^2" + "Kilometer/square second" + "km/s^2" + "Dekameter/square second" + "dam/s^2" + "Hectometer/square second" + "hm/s^2" + "Gal" + "Gal" + "Mercury surface gravity" + "Mercury g" + "Venus surface gravity" + "Venus g" + "Earth surface gravity" + "Earth g" + "Mars surface gravity" + "Mars g" + "Jupiter surface gravity" + "Jupiter g" + "Saturn surface gravity" + "Saturn g" + "Uranus surface gravity" + "Uranus g" + "Neptune surface gravity" + "Neptune g" + "Sun surface gravity" + "Sun g" - - 1inch Network - NCH - Cardano - ADA - United Arab Emirates Dirham - AED - Afghan afghani - AFN - Algorand - LGO - Albanian lek - ALL - Armenian dram - AMD - Netherlands Antillean Guilder - ANG - Angolan kwanza - AOA - Argentine peso - ARS - Atomic Coin - TOM - Australian dollar - AUD - Avalanche - VAX - Aruban florin - AWG - Azerbaijani manat - AZN - Bosnia-Herzegovina Convertible Mark - BAM - Bajan dollar - BBD - Bitcoin Cash - BCH - Bangladeshi taka - BDT - Bulgarian lev - BGN - Bahraini dinar - BHD - Burundian Franc - BIF - Bermudan dollar - BMD - Binance Coin - BNB - Brunei dollar - BND - Bolivian boliviano - BOB - Brazilian real - BRL - Bahamian dollar - BSD - Bitcoin - BTC - Bhutan currency - BTN - Binance USD - USD - Botswanan Pula - BWP - New Belarusian Ruble - BYN - Belarusian Ruble - BYR - Belize dollar - BZD - Canadian dollar - CAD - Congolese franc - CDF - Swiss franc - CHF - Chiliz - CHZ - Chilean Unit of Account (UF) - CLF - Chilean peso - CLP - Chinese Yuan - CNY - Colombian peso - COP - Costa Rican Colón - CRC - Crypto.com Chain Token - CRO - Cuban convertible peso - CUC - Cuban Peso - CUP - Cape Verdean escudo - CVE - Czech koruna - CZK - Dai - DAI - Djiboutian franc - DJF - Danish krone - DKK - Dogecoin - OGE - Dominican peso - DOP - Dotcoin - DOT - Algerian dinar - DZD - Elrond - GLD - Egyptian pound - EGP - Enjin Coin - ENJ - Eritrean nakfa - ERN - Ethiopian birr - ETB - Ethereum Classic - ETC - Ether - ETH - Euro - EUR - FileCoin - FIL - Fijian dollar - FJD - Falkland Islands pound - FKP - FarmaTrust - FTT - Pound sterling - GBP - Georgian lari - GEL - GGPro - GGP - Ghanaian cedi - GHS - Gibraltar pound - GIP - Gambian dalasi - GMD - Guinean franc - GNF - Golden Ratio Token - GRT - Guatemalan quetzal - GTQ - Guyanaese Dollar - GYD - Hong Kong dollar - HKD - Honduran lempira - HNL - Croatian kuna - HRK - Haitian gourde - HTG - Hungarian forint - HUF - Internet Computer - ICP - Indonesian rupiah - IDR - Israeli New Shekel - ILS - CoinIMP - IMP - Injective - INJ - Indian rupee - INR - Iraqi dinar - IQD - Iranian rial - IRR - Icelandic króna - ISK - Jersey Pound - JEP - Jamaican dollar - JMD - Jordanian dinar - JOD - Japanese yen - JPY - Kenyan shilling - KES - Kyrgystani Som - KGS - Cambodian riel - KHR - Comorian franc - KMF - North Korean won - KPW - South Korean won - KRW - Kusama - KSM - Kuwaiti dinar - KWD - Cayman Islands dollar - KYD - Kazakhstani tenge - KZT - Laotian Kip - LAK - Lebanese pound - LBP - ChainLink - INK - Sri Lankan rupee - LKR - Liberian dollar - LRD - Lesotho loti - LSL - Litecoin - LTC - Lithuanian litas - LTL - Luna Coin - UNA - Latvian lats - LVL - Libyan dinar - LYD - Moroccan dirham - MAD - Polygon - TIC - Moldovan leu - MDL - Malagasy ariary - MGA - Macedonian denar - MKD - Myanmar Kyat - MMK - Mongolian tugrik - MNT - Macanese pataca - MOP - Mauritanian ouguiya - MRO - Mauritian rupee - MUR - Maldivian rufiyaa - MVR - Malawian kwacha - MWK - Mexican peso - MXN - Malaysian ringgit - MYR - Mozambican Metical - MZN - Namibian dollar - NAD - Nigerian naira - NGN - Nicaraguan córdoba - NIO - Norwegian krone - NOK - Nepalese rupee - NPR - New Zealand dollar - NZD - Omani rial - OMR - Menlo One - ONE - Panamanian balboa - PAB - Sol - PEN - Papua New Guinean kina - PGK - Philippine peso - PHP - Pakistani rupee - PKR - Poland złoty - PLN - Paraguayan guarani - PYG - Qatari Rial - QAR - Romanian leu - RON - Serbian dinar - RSD - Russian ruble - RUB - Rwandan Franc - RWF - Saudi riyal - SAR - Solomon Islands dollar - SBD - Seychellois rupee - SCR - Sudanese pound - SDG - Swedish krona - SEK - Singapore dollar - SGD - Shiba Inu - HIB - Saint Helena pound - SHP - Sierra Leonean leone - SLL - Sola - SOL - Somali shilling - SOS - Surinamese dollar - SRD - São Tomé and Príncipe Dobra (pre-2018) - STD - Salvadoran Colón - SVC - Syrian pound - SYP - Swazi lilangeni - SZL - Thai baht - THB - Theta - ETA - Tajikistani somoni - TJS - Turkmenistani manat - TMT - Tunisian dinar - TND - Tongan Paʻanga - TOP - TRON - TRX - Turkish lira - TRY - Trinidad & Tobago Dollar - TTD - New Taiwan dollar - TWD - Tanzanian shilling - TZS - Ukrainian hryvnia - UAH - Ugandan shilling - UGX - Universe - UNI - United States dollar - USD - USD Coin - SDC - Tether - SDT - Uruguayan peso - UYU - Uzbekistani som - UZS - Sovereign Bolivar - VEF - Vechain - VET - Vietnamese dong - VND - Vanuatu vatu - VUV - Wrapped Bitcoin - BTC - Samoan tala - WST - Central African CFA franc - XAF - Silver Ounce - XAG - XauCoin - XAU - East Caribbean dollar - XCD - Special Drawing Rights - XDR - Stellar - XLM - Monero - XMR - West African CFA franc - XOF - CFP franc - XPF - XRP - XRP - Yemeni rial - YER - South African rand - ZAR - Zambian kwacha - ZMK - Zambian Kwacha - ZMW - Zimbabwean Dollar - ZWL + + "1inch Network" + "NCH" + "Cardano" + "ADA" + "United Arab Emirates Dirham" + "AED" + "Afghan afghani" + "AFN" + "Algorand" + "LGO" + "Albanian lek" + "ALL" + "Armenian dram" + "AMD" + "Netherlands Antillean Guilder" + "ANG" + "Angolan kwanza" + "AOA" + "Argentine peso" + "ARS" + "Atomic Coin" + "TOM" + "Australian dollar" + "AUD" + "Avalanche" + "VAX" + "Aruban florin" + "AWG" + "Azerbaijani manat" + "AZN" + "Bosnia-Herzegovina Convertible Mark" + "BAM" + "Bajan dollar" + "BBD" + "Bitcoin Cash" + "BCH" + "Bangladeshi taka" + "BDT" + "Bulgarian lev" + "BGN" + "Bahraini dinar" + "BHD" + "Burundian Franc" + "BIF" + "Bermudan dollar" + "BMD" + "Binance Coin" + "BNB" + "Brunei dollar" + "BND" + "Bolivian boliviano" + "BOB" + "Brazilian real" + "BRL" + "Bahamian dollar" + "BSD" + "Bitcoin" + "BTC" + "Bhutan currency" + "BTN" + "Binance USD" + "USD" + "Botswanan Pula" + "BWP" + "New Belarusian Ruble" + "BYN" + "Belarusian Ruble" + "BYR" + "Belize dollar" + "BZD" + "Canadian dollar" + "CAD" + "Congolese franc" + "CDF" + "Swiss franc" + "CHF" + "Chiliz" + "CHZ" + "Chilean Unit of Account (UF)" + "CLF" + "Chilean peso" + "CLP" + "Chinese Yuan" + "CNY" + "Colombian peso" + "COP" + "Costa Rican Colón" + "CRC" + "Crypto.com Chain Token" + "CRO" + "Cuban convertible peso" + "CUC" + "Cuban Peso" + "CUP" + "Cape Verdean escudo" + "CVE" + "Czech koruna" + "CZK" + "Dai" + "DAI" + "Djiboutian franc" + "DJF" + "Danish krone" + "DKK" + "Dogecoin" + "OGE" + "Dominican peso" + "DOP" + "Dotcoin" + "DOT" + "Algerian dinar" + "DZD" + "Elrond" + "GLD" + "Egyptian pound" + "EGP" + "Enjin Coin" + "ENJ" + "Eritrean nakfa" + "ERN" + "Ethiopian birr" + "ETB" + "Ethereum Classic" + "ETC" + "Ether" + "ETH" + "Euro" + "EUR" + "FileCoin" + "FIL" + "Fijian dollar" + "FJD" + "Falkland Islands pound" + "FKP" + "FarmaTrust" + "FTT" + "Pound sterling" + "GBP" + "Georgian lari" + "GEL" + "GGPro" + "GGP" + "Ghanaian cedi" + "GHS" + "Gibraltar pound" + "GIP" + "Gambian dalasi" + "GMD" + "Guinean franc" + "GNF" + "Golden Ratio Token" + "GRT" + "Guatemalan quetzal" + "GTQ" + "Guyanaese Dollar" + "GYD" + "Hong Kong dollar" + "HKD" + "Honduran lempira" + "HNL" + "Croatian kuna" + "HRK" + "Haitian gourde" + "HTG" + "Hungarian forint" + "HUF" + "Internet Computer" + "ICP" + "Indonesian rupiah" + "IDR" + "Israeli New Shekel" + "ILS" + "CoinIMP" + "IMP" + "Injective" + "INJ" + "Indian rupee" + "INR" + "Iraqi dinar" + "IQD" + "Iranian rial" + "IRR" + "Icelandic króna" + "ISK" + "Jersey Pound" + "JEP" + "Jamaican dollar" + "JMD" + "Jordanian dinar" + "JOD" + "Japanese yen" + "JPY" + "Kenyan shilling" + "KES" + "Kyrgystani Som" + "KGS" + "Cambodian riel" + "KHR" + "Comorian franc" + "KMF" + "North Korean won" + "KPW" + "South Korean won" + "KRW" + "Kusama" + "KSM" + "Kuwaiti dinar" + "KWD" + "Cayman Islands dollar" + "KYD" + "Kazakhstani tenge" + "KZT" + "Laotian Kip" + "LAK" + "Lebanese pound" + "LBP" + "ChainLink" + "INK" + "Sri Lankan rupee" + "LKR" + "Liberian dollar" + "LRD" + "Lesotho loti" + "LSL" + "Litecoin" + "LTC" + "Lithuanian litas" + "LTL" + "Luna Coin" + "UNA" + "Latvian lats" + "LVL" + "Libyan dinar" + "LYD" + "Moroccan dirham" + "MAD" + "Polygon" + "TIC" + "Moldovan leu" + "MDL" + "Malagasy ariary" + "MGA" + "Macedonian denar" + "MKD" + "Myanmar Kyat" + "MMK" + "Mongolian tugrik" + "MNT" + "Macanese pataca" + "MOP" + "Mauritanian ouguiya" + "MRO" + "Mauritian rupee" + "MUR" + "Maldivian rufiyaa" + "MVR" + "Malawian kwacha" + "MWK" + "Mexican peso" + "MXN" + "Malaysian ringgit" + "MYR" + "Mozambican Metical" + "MZN" + "Namibian dollar" + "NAD" + "Nigerian naira" + "NGN" + "Nicaraguan córdoba" + "NIO" + "Norwegian krone" + "NOK" + "Nepalese rupee" + "NPR" + "New Zealand dollar" + "NZD" + "Omani rial" + "OMR" + "Menlo One" + "ONE" + "Panamanian balboa" + "PAB" + "Sol" + "PEN" + "Papua New Guinean kina" + "PGK" + "Philippine peso" + "PHP" + "Pakistani rupee" + "PKR" + "Poland złoty" + "PLN" + "Paraguayan guarani" + "PYG" + "Qatari Rial" + "QAR" + "Romanian leu" + "RON" + "Serbian dinar" + "RSD" + "Russian ruble" + "RUB" + "Rwandan Franc" + "RWF" + "Saudi riyal" + "SAR" + "Solomon Islands dollar" + "SBD" + "Seychellois rupee" + "SCR" + "Sudanese pound" + "SDG" + "Swedish krona" + "SEK" + "Singapore dollar" + "SGD" + "Shiba Inu" + "HIB" + "Saint Helena pound" + "SHP" + "Sierra Leonean leone" + "SLL" + "Sola" + "SOL" + "Somali shilling" + "SOS" + "Surinamese dollar" + "SRD" + "São Tomé and Príncipe Dobra (pre-2018)" + "STD" + "Salvadoran Colón" + "SVC" + "Syrian pound" + "SYP" + "Swazi lilangeni" + "SZL" + "Thai baht" + "THB" + "Theta" + "ETA" + "Tajikistani somoni" + "TJS" + "Turkmenistani manat" + "TMT" + "Tunisian dinar" + "TND" + "Tongan Paʻanga" + "TOP" + "TRON" + "TRX" + "Turkish lira" + "TRY" + "Trinidad & Tobago Dollar" + "TTD" + "New Taiwan dollar" + "TWD" + "Tanzanian shilling" + "TZS" + "Ukrainian hryvnia" + "UAH" + "Ugandan shilling" + "UGX" + "Universe" + "UNI" + "United States dollar" + "USD" + "USD Coin" + "SDC" + "Tether" + "SDT" + "Uruguayan peso" + "UYU" + "Uzbekistani som" + "UZS" + "Sovereign Bolivar" + "VEF" + "Vechain" + "VET" + "Vietnamese dong" + "VND" + "Vanuatu vatu" + "VUV" + "Wrapped Bitcoin" + "BTC" + "Samoan tala" + "WST" + "Central African CFA franc" + "XAF" + "Silver Ounce" + "XAG" + "XauCoin" + "XAU" + "East Caribbean dollar" + "XCD" + "Special Drawing Rights" + "XDR" + "Stellar" + "XLM" + "Monero" + "XMR" + "West African CFA franc" + "XOF" + "CFP franc" + "XPF" + "XRP" + "XRP" + "Yemeni rial" + "YER" + "South African rand" + "ZAR" + "Zambian kwacha" + "ZMK" + "Zambian Kwacha" + "ZMW" + "Zimbabwean Dollar" + "ZWL" - - Maxwell - Mx - Weber - Wb - Milliweber - mWb - Microweber - μWb - Kiloweber - kWb - Megaweber - MWb - Gigaweber - GWb + + "Length" + "Time" + "Volume" + "Area" + "Temperature" + "Speed" + "Mass" + "Data" + "Energy" + "Power" + "Angle" + "Data transfer" + "Pressure" + "Acceleration" + "Currency" - - Binary - base2 - Ternary - base3 - Quaternary - base4 - Quinary - base5 - Senary - base6 - Septenary - base7 - Octal - base8 - Nonary - base9 - Decimal - base10 - Undecimal - base11 - Duodecimal - base12 - Tridecimal - base13 - Tetradecimal - base14 - Pentadecimal - base15 - Hexadecimal - base16 + + "Convert from" + "Convert to" + "Settings" - - Attofarad - aF - Statfarad - stF - Farad - F - Exafarad - EF - Picofarad - pF - Nanofarad - nF - Microfarad - µF - Millifarad - mF - Kilofarad - kF - Megafarad - MF - Gigafarad - GF - Petafarad - PF + + "Themes" + "Precision" + "Separator" + "Exponential notation" + "Unit groups" + "Wrong currency rates?" + "Note" + "Currency rates are updated daily. There's no real-time market monitoring in the app" + "Terms and Conditions" + "Privacy Policy" + "Third party licenses" + "Rate this app" + "Formatting" + "Additional" - - Quetta - Q - Ronna - R - Yotta - Y - Zetta - Z - Exa - E - Peta - P - Tera - T - Giga - G - Mega - M - Kilo - k - Hecto - h - Deca - da - Base - Base - Deci - d - Centi - c - Milli - m - Micro - μ - Nano - n - Pico - p - Femto - f - Atto - a - Zepto - z - Yocto - y - Ronto - r - Quecto - q + + "Number of decimal places" + "Converted values may have a precision higher than the preferred one." + "1 000 (Max)" - - Newton - N - Kilonewton - kN - Gram-force - gf - Kilogram-force - kgf - Ton-force - tf - Millinewton - mN - Attonewton - aN - Dyne - dyn - Joule/meter - J/m - Joule/centimeter - J/cm - Kilopound-force - kipf - Pound-force - lbf - Ounce-force - ozf - Pond - p - Kilopond - kp + + "Group separator symbol" + "Period" + "Comma" + "Spaces" - - Newton meter - N*m - Newton centimeter - N*cm - Newton millimeter - N*mm - Kilonewton meter - kN*m - Dyne meter - dyn*m - Dyne centimeter - dyn*cm - Dyne millimeter - dyn*mm - Kilogram-force meter - kgf*m - Kilogram-force centimeter - kgf*cm - Kilogram-force millimeter - kgf*mm - Gram-force meter - gf*m - Gram-force centimeter - gf*cm - Gram-force millimeter - gf*mm - Ounce-force foot - ozf*ft - Ounce-force inch - ozf*in - Pound-force foot - lbf*ft - Pound-force inch - lbf*in + + "Replace part of the number with E" - - Liter/hour - L/h - Liter/minute - L/m - Liter/second - L/s - Milliliter/hour - mL/h - Milliliter/minute - mL/m - Milliliter/second - mL/s - Cubic Meter/hour - m3/h - Cubic Meter/minute - m3/m - Cubic Meter/second - m3/s - Cubic Millimeter/hour - mm3/h - Cubic Millimeter/minute - mm3/m - Cubic Millimeter/second - mm3/s - Cubic Foot/hour - ft3/h - Cubic Foot/minute - ft3/m - Cubic Foot/second - ft3/s - Gallon/hour (U.S.) - gal/h - Gallon/minute (U.S.) - gal/m - Gallon/second (U.S.) - gal/s - Gallon/hour (Imperial) - gal/h - Gallon/minute (Imperial) - gal/m - Gallon/second (Imperial) - gal/s + + "App look and feel" + "Auto" + "Light" + "Dark" + "Color theme" + "AMOLED Dark" + "Use black background for dark themes" + "Dynamic colors" + "Use colors from your wallpaper" - - Candela/square meter - cd/m^2 - Candela/square centimeter - cd/cm^2 - Candela/square foot - cd/ft^2 - Candela/square inch - cd/in^2 - Kilocandela/square meter - kcd - Stilb - sb - Lumen/square meter/steradian - lm/m^2/sr - Lumen/square centimeter/steradian - lm/cm^2/sr - Lumen/square foot/steradian - lm/ft^2/sr - Watt/square centimeter/steradian - W/cm^2/sr - Nit - nt - Millinit - mnt - Lambert - L - Millilambert - mL - Foot-lambert - fL - Apostilb - asb - Blondel - blondel - Bril - bril - Skot - sk + + "Loading…" + "Error" + "Copied %1$s!" + "Cancel" + "OK" + "Search units" + "No results found" + "Open settings" + "Make sure there are no typos, try different filters or check for disabled unit groups." + "Hello!" + "Enabled" + "Disabled" - - Length - Time - Volume - Area - Temperature - Speed - Mass - Data - Energy - Power - Angle - Data transfer - Pressure - Acceleration - Currency - Flux - Base - Capacitance - Prefix - Force - Torque - Flow - Luminance + + "Navigate up" + "Checked filter" + "Open settings" + "Swap units" + "Search button" + "Clear input" + "Add or remove unit from favorites" + "Empty search result" + "Open or close drop down menu" + "Enable unit group" + "Reorder unit group" + "Disable unit group" + "Version name" + "About Unitto" + "Learn about the app" + "Disable and rearrange units" + "Cent" + "cent" - - Convert from - Convert to - Settings + + "Maxwell" + "Mx" + "Weber" + "Wb" + "Milliweber" + "mWb" + "Microweber" + "μWb" + "Kiloweber" + "kWb" + "Megaweber" + "MWb" + "Gigaweber" + "GWb" + "Flux" + "View source code" + "Translate this app" + "Join POEditor project to help" - - Themes - Precision - Separator - Output format - Starting screen - Choose which screen is shown when you launch the app - Unit groups - Vibrations - Haptic feedback when clicking keyboard buttons - Format time - Example: Show 130 minutes as 2h 10m - Units list sorting - Change units order - Wrong currency rates? - Note - Currency rates are updated daily. There\'s no real-time market monitoring in the app - Terms and Conditions - Privacy Policy - View source code - Translate this app - Join POEditor project to help - Third party licenses - Rate this app - Formatting - Precision and numbers appearance - Additional + + "Binary" + "base2" + "Ternary" + "base3" + "Quaternary" + "base4" + "Quinary" + "base5" + "Senary" + "base6" + "Septenary" + "base7" + "Octal" + "base8" + "Nonary" + "base9" + "Decimal" + "base10" + "Undecimal" + "base11" + "Duodecimal" + "base12" + "Tridecimal" + "base13" + "Tetradecimal" + "base14" + "Pentadecimal" + "base15" + "Hexadecimal" + "base16" + "Base" + "Vibrations" + "Haptic feedback when clicking keyboard buttons" + "Millibar" + "mbar" + "Kilopascal" + "kPa" + "Micron of mercury" + "μmHg" - - Unit converter - Epoch converter + + "Epoch converter" - - Calculator - Clear - Clear history - All expressions from history will be deleted forever. This action can\'t be undone! - No history - Can\'t divide by 0 + + "Calculator" - Date difference - Select time - Start - End - Difference - Years - Months - Days - Hours - Minute - Next + + "y" + "m" + "Nautical mile" + "M" + "Starting screen" + "Choose which screen is shown when you launch the app" - - Number of decimal places - Converted values may have a precision higher than the preferred one. - 0 - 1 - 2 - 3 - 4 - 5 - 6 - 7 - 8 - 9 - 10 - 11 - 12 - 13 - 14 - 15 - 1 000 (Max) + + "Unit converter" + "Clear" + "Clear history" + "All expressions from history will be deleted forever. This action can't be undone!" + "No history" + "Open menu" + "Microgram" + "µg" - - Group separator symbol - Period - Comma - Spaces + + "Attofarad" + "aF" + "Statfarad" + "stF" + "Farad" + "F" + "Exafarad" + "EF" + "Picofarad" + "pF" + "Nanofarad" + "nF" + "Microfarad" + "µF" + "Millifarad" + "mF" + "Kilofarad" + "kF" + "Megafarad" + "MF" + "Gigafarad" + "GF" + "Petafarad" + "PF" - - Result value formatting - Engineering strings look like 1E-21 - Default - Allow engineering - Force engineering + + "Quetta" + "Q" + "Ronna" + "R" + "Yotta" + "Y" + "Zetta" + "Z" + "Exa" + "E" + "Peta" + "P" + "Tera" + "T" + "Giga" + "G" + "Mega" + "M" + "Kilo" + "k" + "Hecto" + "h" + "Deca" + "da" + "Base" + "Base" + "Deci" + "d" + "Centi" + "c" + "Milli" + "m" + "Micro" + "μ" + "Nano" + "n" + "Pico" + "p" + "Femto" + "f" + "Atto" + "a" + "Zepto" + "z" + "Yocto" + "y" + "Ronto" + "r" + "Quecto" + "q" - - Usage - Alphabetical - Scale (Desc.) - Scale (Asc.) + + "Newton" + "N" + "Kilonewton" + "kN" + "Gram-force" + "gf" + "Kilogram-force" + "kgf" + "Ton-force" + "tf" + "Millinewton" + "mN" + "Attonewton" + "aN" + "Dyne" + "dyn" + "Joule/meter" + "J/m" + "Joule/centimeter" + "J/cm" + "Kilopound-force" + "kipf" + "Pound-force" + "lbf" + "Ounce-force" + "ozf" + "Pond" + "p" + "Kilopond" + "kp" - - App look and feel - Auto - Light - Dark - Color theme - Pick a theming mode - AMOLED Dark - Use black background for dark themes - Dynamic colors - Use colors from your wallpaper - Color scheme - Selected color - Selected style + + "Newton meter" + "N*m" + "Newton centimeter" + "N*cm" + "Newton millimeter" + "N*mm" + "Kilonewton meter" + "kN*m" + "Dyne meter" + "dyn*m" + "Dyne centimeter" + "dyn*cm" + "Dyne millimeter" + "dyn*mm" + "Kilogram-force meter" + "kgf*m" + "Kilogram-force centimeter" + "kgf*cm" + "Kilogram-force millimeter" + "kgf*mm" + "Gram-force meter" + "gf*m" + "Gram-force centimeter" + "gf*cm" + "Gram-force millimeter" + "gf*mm" + "Ounce-force foot" + "ozf*ft" + "Ounce-force inch" + "ozf*in" + "Pound-force foot" + "lbf*ft" + "Pound-force inch" + "lbf*in" - - Loading… - Error - Copied %1$s! - Cancel - OK - Search units - No results found - Open settings - Make sure there are no typos, try different filters or check for disabled unit groups. - Hello! - Enabled - Disabled + + "Liter/hour" + "L/h" + "Liter/minute" + "L/m" + "Liter/second" + "L/s" + "Milliliter/hour" + "mL/h" + "Milliliter/minute" + "mL/m" + "Milliliter/second" + "mL/s" + "Cubic Meter/hour" + "m3/h" + "Cubic Meter/minute" + "m3/m" + "Cubic Meter/second" + "m3/s" + "Cubic Millimeter/hour" + "mm3/h" + "Cubic Millimeter/minute" + "mm3/m" + "Cubic Millimeter/second" + "mm3/s" + "Cubic Foot/hour" + "ft3/h" + "Cubic Foot/minute" + "ft3/m" + "Cubic Foot/second" + "ft3/s" + "Gallon/hour (U.S.)" + "gal/h" + "Gallon/minute (U.S.)" + "gal/m" + "Gallon/second (U.S.)" + "gal/s" + "Gallon/hour (Imperial)" + "gal/h" + "Gallon/minute (Imperial)" + "gal/m" + "Gallon/second (Imperial)" + "gal/s" - - Navigate up - Checked filter - Open settings - Swap units - Search button - Clear input - Add or remove unit from favorites - Empty search result - Open or close drop down menu - Enable unit group - Reorder unit group - Disable unit group - Open menu + + "Candela/square meter" + "cd/m^2" + "Candela/square centimeter" + "cd/cm^2" + "Candela/square foot" + "cd/ft^2" + "Candela/square inch" + "cd/in^2" + "Kilocandela/square meter" + "kcd" + "Stilb" + "sb" + "Lumen/square meter/steradian" + "lm/m^2/sr" + "Lumen/square centimeter/steradian" + "lm/cm^2/sr" + "Lumen/square foot/steradian" + "lm/ft^2/sr" + "Watt/square centimeter/steradian" + "W/cm^2/sr" + "Nit" + "nt" + "Millinit" + "mnt" + "Lambert" + "L" + "Millilambert" + "mL" + "Foot-lambert" + "fL" + "Apostilb" + "asb" + "Blondel" + "blondel" + "Bril" + "bril" + "Skot" + "sk" + "Capacitance" + "Prefix" + "Force" + "Torque" + "Flow" + "Luminance" + "Format time" + "Example: Show 130 minutes as 2h 10m" + "Units list sorting" + "Change units order" - Version name - About Unitto - Learn about the app - Disable and rearrange units + + "Usage" + "Alphabetical" + "Scale (Desc.)" + "Scale (Asc.)" + "Pick a theming mode" + "Color scheme" + "Selected color" + "Selected style" - - y - m + + "Precision and numbers appearance" + "Can't divide by 0" + "Date difference" + + + "Select time" + "Start" + "End" + "Difference" + + + "Years" + + + "Months" + + + "Days" + + + "Hours" + + + "Minute" + + + "Next" \ No newline at end of file diff --git a/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/SettingsScreen.kt b/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/SettingsScreen.kt index 194cd602..e0f97a18 100644 --- a/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/SettingsScreen.kt +++ b/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/SettingsScreen.kt @@ -112,11 +112,11 @@ internal fun SettingsScreen( leadingContent = { Icon( Icons.Default._123, - stringResource(R.string.formatting_settings_group), + stringResource(R.string.formatting_setting), ) }, - headlineContent = { Text(stringResource(R.string.formatting_settings_group)) }, - supportingContent = { Text(stringResource(R.string.formatting_settings_support)) }, + headlineContent = { Text(stringResource(R.string.formatting_setting)) }, + supportingContent = { Text(stringResource(R.string.formatting_setting_support)) }, modifier = Modifier.clickable { navControllerAction(formattingRoute) } ) } diff --git a/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/formatting/FormattingScreen.kt b/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/formatting/FormattingScreen.kt index 7f1a2940..d5872598 100644 --- a/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/formatting/FormattingScreen.kt +++ b/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/formatting/FormattingScreen.kt @@ -89,7 +89,7 @@ fun FormattingScreen( precisions: ClosedFloatingPointRange = 0f..16f, // 16th is a MAX_PRECISION (1000) ) { UnittoScreenWithLargeTopBar( - title = stringResource(R.string.formatting_settings_group), + title = stringResource(R.string.formatting_setting), navigationIcon = { NavigateUpButton(navigateUpAction) }, ) { paddingValues -> LazyColumn( @@ -198,8 +198,8 @@ fun FormattingScreen( leadingContent = { Icon(Icons.Default.EMobiledata, stringResource(R.string.precision_setting)) }, - headlineContent = { Text(stringResource(R.string.output_format_setting)) }, - supportingContent = { Text(stringResource(R.string.output_format_setting_support)) } + headlineContent = { Text(stringResource(R.string.exponential_notation_setting)) }, + supportingContent = { Text(stringResource(R.string.exponential_notation_setting_support)) } ) } @@ -212,20 +212,20 @@ fun FormattingScreen( ) { SegmentedButtonsRow { SegmentedButton( - label = stringResource(R.string.plain), - onClick = { onOutputFormatChange(OutputFormat.PLAIN) }, - selected = OutputFormat.PLAIN == uiState.outputFormat - ) - SegmentedButton( - label = stringResource(R.string.allow_engineering), + label = stringResource(R.string.auto_label), onClick = { onOutputFormatChange(OutputFormat.ALLOW_ENGINEERING) }, selected = OutputFormat.ALLOW_ENGINEERING == uiState.outputFormat ) SegmentedButton( - label = stringResource(R.string.force_engineering), + label = stringResource(R.string.enabled_label), onClick = { onOutputFormatChange(OutputFormat.FORCE_ENGINEERING) }, selected = OutputFormat.FORCE_ENGINEERING == uiState.outputFormat ) + SegmentedButton( + label = stringResource(R.string.disabled_label), + onClick = { onOutputFormatChange(OutputFormat.PLAIN) }, + selected = OutputFormat.PLAIN == uiState.outputFormat + ) } } } diff --git a/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/themes/ThemesScreen.kt b/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/themes/ThemesScreen.kt index 6448c2bc..6bf6a234 100644 --- a/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/themes/ThemesScreen.kt +++ b/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/themes/ThemesScreen.kt @@ -137,7 +137,7 @@ private fun ThemesScreen( val themingModes by remember { mutableStateOf( mapOf( - ThemingMode.AUTO to (R.string.force_auto_mode to Icons.Outlined.HdrAuto), + ThemingMode.AUTO to (R.string.auto_label to Icons.Outlined.HdrAuto), ThemingMode.FORCE_LIGHT to (R.string.force_light_mode to Icons.Outlined.LightMode), ThemingMode.FORCE_DARK to (R.string.force_dark_mode to Icons.Outlined.DarkMode) ) From 0b5066fa1883c68a92db9b95a9b5be6366b6bdc8 Mon Sep 17 00:00:00 2001 From: Sad Ellie Date: Tue, 23 May 2023 23:49:54 +0300 Subject: [PATCH 28/51] Inline theming modes --- .../feature/settings/themes/ThemesScreen.kt | 39 +++++++++---------- 1 file changed, 18 insertions(+), 21 deletions(-) diff --git a/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/themes/ThemesScreen.kt b/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/themes/ThemesScreen.kt index 6bf6a234..22cb2c82 100644 --- a/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/themes/ThemesScreen.kt +++ b/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/themes/ThemesScreen.kt @@ -41,8 +41,6 @@ import androidx.compose.material3.Icon import androidx.compose.material3.ListItem import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -134,16 +132,6 @@ private fun ThemesScreen( monetMode: MonetMode, onMonetModeChange: (MonetMode) -> Unit ) { - val themingModes by remember { - mutableStateOf( - mapOf( - ThemingMode.AUTO to (R.string.auto_label to Icons.Outlined.HdrAuto), - ThemingMode.FORCE_LIGHT to (R.string.force_light_mode to Icons.Outlined.LightMode), - ThemingMode.FORCE_DARK to (R.string.force_dark_mode to Icons.Outlined.DarkMode) - ) - ) - } - UnittoScreenWithLargeTopBar( title = stringResource(R.string.theme_setting), navigationIcon = { NavigateUpButton(navigateUpAction) } @@ -170,15 +158,24 @@ private fun ThemesScreen( .wrapContentWidth() ) { SegmentedButtonsRow(modifier = Modifier.padding(56.dp, 8.dp, 24.dp, 2.dp)) { - themingModes.forEach { (mode, visuals) -> - val (label, icon) = visuals - SegmentedButton( - label = stringResource(label), - onClick = { onThemeChange(mode) }, - selected = mode == currentThemingMode, - icon = icon - ) - } + SegmentedButton( + label = stringResource(R.string.auto_label), + onClick = { onThemeChange(ThemingMode.AUTO) }, + selected = ThemingMode.AUTO == currentThemingMode, + icon = Icons.Outlined.HdrAuto + ) + SegmentedButton( + label = stringResource(R.string.force_light_mode), + onClick = { onThemeChange(ThemingMode.FORCE_LIGHT) }, + selected = ThemingMode.FORCE_LIGHT == currentThemingMode, + icon = Icons.Outlined.LightMode + ) + SegmentedButton( + label = stringResource(R.string.force_dark_mode), + onClick = { onThemeChange(ThemingMode.FORCE_DARK) }, + selected = ThemingMode.FORCE_DARK == currentThemingMode, + icon = Icons.Outlined.DarkMode + ) } } } From e2b48ef57dfabd471aa032fae32d2bccffb8a860 Mon Sep 17 00:00:00 2001 From: Sad Ellie Date: Wed, 24 May 2023 16:55:45 +0300 Subject: [PATCH 29/51] Sync localizations with POEditor Also little changes in text --- .../sadellie/unitto/core/base/Separator.kt | 2 +- core/base/src/main/res/values-de/strings.xml | 48 +++++++++++++++++-- .../src/main/res/values-en-rGB/strings.xml | 10 ++-- core/base/src/main/res/values-fr/strings.xml | 10 ++-- core/base/src/main/res/values-it/strings.xml | 43 +++++++++-------- core/base/src/main/res/values-ru/strings.xml | 8 ++-- core/base/src/main/res/values/strings.xml | 6 +-- .../ui/common/textfield/FormatterSymbols.kt | 2 +- .../unitto/data/userprefs/UserPreferences.kt | 6 +-- .../settings/formatting/FormattingScreen.kt | 33 ++++++++++--- .../settings/formatting/FormattingUIState.kt | 5 +- .../formatting/FormattingViewModel.kt | 11 +++-- 12 files changed, 126 insertions(+), 58 deletions(-) diff --git a/core/base/src/main/java/com/sadellie/unitto/core/base/Separator.kt b/core/base/src/main/java/com/sadellie/unitto/core/base/Separator.kt index 7eae5336..074f8308 100644 --- a/core/base/src/main/java/com/sadellie/unitto/core/base/Separator.kt +++ b/core/base/src/main/java/com/sadellie/unitto/core/base/Separator.kt @@ -22,7 +22,7 @@ package com.sadellie.unitto.core.base * Separators mean symbols that separate fractional part */ object Separator { - const val SPACES = 0 + const val SPACE = 0 const val PERIOD = 1 const val COMMA = 2 } diff --git a/core/base/src/main/res/values-de/strings.xml b/core/base/src/main/res/values-de/strings.xml index 0e12262b..8d0a90d6 100644 --- a/core/base/src/main/res/values-de/strings.xml +++ b/core/base/src/main/res/values-de/strings.xml @@ -676,13 +676,19 @@ Anzahl der Dezimalstellen Umgerechnete Werte können eine höhere Präzision als eingestellt haben. - 1 000 (Maximum) + %1$s (Maximum) Gruppentrennzeichen - Punkt (42.069,12) - Komma (42,069.12) - Leerzeichen (42 069.12) + Punkt + Komma + + + Leerzeichen + + + + Einen Teil der Nummer durch E ersetzen App Aussehen @@ -1099,4 +1105,38 @@ Farbschema Ausgewählte Farbe Stil auswählen + + + Genauigkeit und Zahlendarstellung + Kann nicht durch 0 teilen + Datumsunterschied + + + Zeit auswählen + Anfang + Ende + Unterschied + + + Jahre + + + Monate + + + Tage + + + Stunden + + + Minuten + + + Weiter \ No newline at end of file diff --git a/core/base/src/main/res/values-en-rGB/strings.xml b/core/base/src/main/res/values-en-rGB/strings.xml index e7896fde..5fad4d5d 100644 --- a/core/base/src/main/res/values-en-rGB/strings.xml +++ b/core/base/src/main/res/values-en-rGB/strings.xml @@ -674,13 +674,13 @@ Number of decimal places Converted values may have a precision higher than the preferred one. - 1 000 (Max) + %1$s (Max) Group separator symbol - Period (42.069,12) - Comma (42,069.12) - Spaces (42 069.12) + Period + Comma + Space Replace part of the number with E @@ -825,7 +825,7 @@ Maybe this can be labeled better? Let me know. It should be something that can d Hours - Minute + Minutes Nombre de décimales Les valeurs converties peuvent avoir une précision supérieure à la précision préférée. - 1 000 (Max) + %1$s (Max) Symbole de séparation de groupe - Période (42.069,12) - Virgule (42,069.12) - Espaces (42 069.12) + Période + Virgule + + + Espaces Auto Clair Sombre diff --git a/core/base/src/main/res/values-it/strings.xml b/core/base/src/main/res/values-it/strings.xml index 3a59795a..2097e063 100644 --- a/core/base/src/main/res/values-it/strings.xml +++ b/core/base/src/main/res/values-it/strings.xml @@ -659,8 +659,6 @@ "Temi" "Precisione" "Separatore" - - "Notazione esponenziale" "Gruppi di unità" "Tassi di cambio sbagliati?" @@ -670,22 +668,25 @@ "Politica sulla riservatezza" "Licenze terze parti" "Valuta questa app" - "Formattazione" + "Formato" "Aggiuntive" "Numero di posti decimali" "Valori convertiti potrebbero avere una precisione maggiore di quella preferita." - "1 000 (Max)" + "%1$s (Max)" "Simbolo separatore gruppo" - "Punto (42.069,12)" - "Virgola (42,069.12)" - "Spazio (42 069.12)" + "Punto" + "Virgola" + + + + "Spazi" - "Replace part of the number with E" + "Sostituisce parte del numero con E" "Aspetto dell'app" @@ -1070,33 +1071,33 @@ https://s3.eu-west-1.amazonaws.com/po-pub/i/prtM85P6x1fMuLg1I0zbkceo.png Maybe this can be labeled better? Let me know. It should be something that can describe content of the Formatting screen. --> - "Precision and numbers appearance" - "Can't divide by 0" - "Date difference" + "Precisione e aspetto dei numeri" + "Non divisibile per 0" + "Differenza di data" - "Select time" - "Start" - "End" - "Difference" + "Seleziona orario" + "Inizio" + "Fine" + "Differenza" - "Years" + "Anni" - "Months" + "Mesi" - "Days" + "Giorni" - "Hours" + "Ore" - "Minute" + "Minuti" - "Next" + "Prossimo" \ No newline at end of file diff --git a/core/base/src/main/res/values-ru/strings.xml b/core/base/src/main/res/values-ru/strings.xml index c63e1eeb..20fb049e 100644 --- a/core/base/src/main/res/values-ru/strings.xml +++ b/core/base/src/main/res/values-ru/strings.xml @@ -674,13 +674,13 @@ Количество десятичных знаков Переводимые значения могут иметь точность выше предпочтительной. - 1 000 (Максимум) + %1$s (Максимум) Символ разделителя - Точка (42.069,12) - Запятая (42,069.12) - Пробел (42 069.12) + Точка + Запятая + Пробел Замените часть числа на E diff --git a/core/base/src/main/res/values/strings.xml b/core/base/src/main/res/values/strings.xml index 16714fc9..cdb9da72 100644 --- a/core/base/src/main/res/values/strings.xml +++ b/core/base/src/main/res/values/strings.xml @@ -937,13 +937,13 @@ "Number of decimal places" "Converted values may have a precision higher than the preferred one." - "1 000 (Max)" + "%1$s (Max)" "Group separator symbol" "Period" "Comma" - "Spaces" + "Space" "Replace part of the number with E" @@ -1355,7 +1355,7 @@ Maybe this can be labeled better? Let me know. It should be something that can d "Hours" - "Minute" + "Minutes" - Attoliter + Attolitre aL - Milliliter + Millilitre mL - Liter + Litre L US liquid gallon gal (US) @@ -783,6 +783,272 @@ Base Vibrations Haptic feedback when clicking keyboard buttons + Millibar + mbar + Kilopascal + kPa + Micron of mercury + μmHg + + + Epoch converter + + + Calculator + + + y + m + Nautical mile + M + Starting screen + Choose which screen is shown when you launch the app + + + Unit converter + Clear + Clear history + All expressions from history will be deleted forever. This action can\'t be undone! + No history + Open menu + Microgram + µg + + + Attofarad + aF + Statfarad + stF + Farad + F + Exafarad + EF + Picofarad + pF + Nanofarad + nF + Microfarad + µF + Millifarad + mF + Kilofarad + kF + Megafarad + MF + Gigafarad + GF + Petafarad + PF + + + Quetta + Q + Ronna + R + Yotta + Y + Zetta + Z + Exa + E + Peta + P + Tera + T + Giga + G + Mega + M + Kilo + k + Hecto + h + Deca + da + Base + Base + Deci + d + Centi + c + Milli + m + Micro + μ + Nano + n + Pico + p + Femto + f + Atto + a + Zepto + z + Yocto + y + Ronto + r + Quecto + q + + + Newton + N + Kilonewton + kN + Gram-force + gf + Kilogram-force + kgf + Ton-force + tf + Millinewton + mN + Attonewton + aN + Dyne + dyn + Joule/metre + J/m + Joule/centimetre + J/cm + Kilopound-force + kipf + Pound-force + lbf + Ounce-force + ozf + Pond + p + Kilopond + kp + + + Newton metre + N*m + Newton centimetre + N*cm + Newton millimetre + N*mm + Kilonewton metre + kN*m + Dyne metre + dyn*m + Dyne centimetre + dyn*cm + Dyne millimetre + dyn*mm + Kilogram-force metre + kgf*m + Kilogram-force centimetre + kgf*cm + Kilogram-force millimetre + kgf*mm + Gram-force metre + gf*m + Gram-force centimetre + gf*cm + Gram-force millimetre + gf*mm + Ounce-force foot + ozf*ft + Ounce-force inch + ozf*in + Pound-force foot + lbf*ft + Pound-force inch + lbf*in + + + Litre/hour + L/h + Litre/minute + L/m + Litre/second + L/s + Millilitre/hour + mL/h + Millilitre/minute + mL/m + Millilitre/second + mL/s + Cubic Metre/hour + m3/h + Cubic Metre/minute + m3/m + Cubic Metre/second + m3/s + Cubic Millimetre/hour + mm3/h + Cubic Millimetre/minute + mm3/m + Cubic Millimetre/second + mm3/s + Cubic Foot/hour + ft3/h + Cubic Foot/minute + ft3/m + Cubic Foot/second + ft3/s + Gallon/hour (U.S.) + gal/h + Gallon/minute (U.S.) + gal/m + Gallon/second (U.S.) + gal/s + Gallon/hour (Imperial) + gal/h + Gallon/minute (Imperial) + gal/m + Gallon/second (Imperial) + gal/s + + + Candela/square metre + cd/m^2 + Candela/square centimetre + cd/cm^2 + Candela/square foot + cd/ft^2 + Candela/square inch + cd/in^2 + Kilocandela/square metre + kcd + Stilb + sb + Lumen/square metre/steradian + lm/m^2/sr + Lumen/square centimetre/steradian + lm/cm^2/sr + Lumen/square foot/steradian + lm/ft^2/sr + Watt/square centimetre/steradian + W/cm^2/sr + Nit + nt + Millinit + mnt + Lambert + L + Millilambert + mL + Foot-lambert + fL + Apostilb + asb + Blondel + blondel + Bril + bril + Skot + sk + Capacitance + Prefix + Force + Torque + Flow + Luminance Format time Example: Show 130 minutes as 2h 10m Units list sorting @@ -831,4 +1097,5 @@ Maybe this can be labeled better? Let me know. It should be something that can d Used in this dialog window. Should be short --> Next + Preview (click to switch) \ No newline at end of file diff --git a/core/base/src/main/res/values-it/strings.xml b/core/base/src/main/res/values-it/strings.xml index 2097e063..cfe97c94 100644 --- a/core/base/src/main/res/values-it/strings.xml +++ b/core/base/src/main/res/values-it/strings.xml @@ -680,10 +680,7 @@ "Simbolo separatore gruppo" "Punto" "Virgola" - - - - "Spazi" + "Spazio" "Sostituisce parte del numero con E" @@ -1099,5 +1096,6 @@ Maybe this can be labeled better? Let me know. It should be something that can d - "Prossimo" + "Avanti" + "Anteprima (tocca per cambiare)" \ No newline at end of file diff --git a/core/base/src/main/res/values-ru/strings.xml b/core/base/src/main/res/values-ru/strings.xml index 20fb049e..ffed6130 100644 --- a/core/base/src/main/res/values-ru/strings.xml +++ b/core/base/src/main/res/values-ru/strings.xml @@ -1097,4 +1097,5 @@ Maybe this can be labeled better? Let me know. It should be something that can d Used in this dialog window. Should be short --> Далее + Предпросмотр (нажмите для переключения) \ No newline at end of file diff --git a/core/base/src/main/res/values/strings.xml b/core/base/src/main/res/values/strings.xml index cdb9da72..451ab317 100644 --- a/core/base/src/main/res/values/strings.xml +++ b/core/base/src/main/res/values/strings.xml @@ -1361,4 +1361,5 @@ Maybe this can be labeled better? Let me know. It should be something that can d Used in this dialog window. Should be short --> "Next" + "Preview (click to switch)" \ No newline at end of file diff --git a/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/formatting/FormattingScreen.kt b/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/formatting/FormattingScreen.kt index 1000a130..1d4e624f 100644 --- a/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/formatting/FormattingScreen.kt +++ b/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/formatting/FormattingScreen.kt @@ -129,7 +129,7 @@ fun FormattingScreen( .padding(16.dp) ) { Text( - text = "Preview (click to switch)", + text = stringResource(R.string.formatting_setting_preview_box_label), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSecondaryContainer ) From f7a7da39ab063b48c4772871d6b3bb115f721f52 Mon Sep 17 00:00:00 2001 From: Sad Ellie Date: Sat, 27 May 2023 20:25:46 +0300 Subject: [PATCH 37/51] Updated README.md --- FUNDING.md | 9 --------- README.md | 35 +++++++++++++++-------------------- content/appGallery.png | Bin 12212 -> 0 bytes content/donate.png | Bin 41629 -> 0 bytes content/nashStore.png | Bin 17093 -> 0 bytes content/poeditor.png | Bin 11483 -> 0 bytes content/progress.png | Bin 56445 -> 0 bytes content/ruMarket.png | Bin 10915 -> 0 bytes content/ruStore.png | Bin 8331 -> 0 bytes content/unittoModules.png | Bin 51263 -> 0 bytes 10 files changed, 15 insertions(+), 29 deletions(-) delete mode 100644 FUNDING.md delete mode 100644 content/appGallery.png delete mode 100644 content/donate.png delete mode 100644 content/nashStore.png delete mode 100644 content/poeditor.png delete mode 100644 content/progress.png delete mode 100644 content/ruMarket.png delete mode 100644 content/ruStore.png delete mode 100644 content/unittoModules.png diff --git a/FUNDING.md b/FUNDING.md deleted file mode 100644 index 584a724d..00000000 --- a/FUNDING.md +++ /dev/null @@ -1,9 +0,0 @@ -

- -

- -I don't need your money, just help Unitto gain **more users**: -- Tell your relatives -- Tell your friends -- Tell strangers on the streets -- Spread the word, take over the world... or something like that diff --git a/README.md b/README.md index 5ac6939a..5b338eb8 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,7 @@ ## 📲 Download Google Play -F-Droid -Rustore +F-Droid ## 😎 Features - **Instant** expression evaluation @@ -29,25 +28,21 @@ - Customizable number **formatter** - **SI Standard** -## 👅 Translate -Unitto - Calculate and convert, but better. | POEditor +## 👅 [Translate](https://poeditor.com/join/project/T4zjmoq8dx) +Join on **POEditor** to help. -## 🤑 Donate -Visit [FUNDING.md](./FUNDING.md) +## 💡 [Open issues](https://github.com/sadellie/unitto/issues/new) +Report bugs or request improvements. I may close your issue as not planned and reopen it later (things change). + +## 🎤 [Start discussions](https://github.com/sadellie/unitto/discussions/new/choose) +If you think that your question will not fit in "Issues", start a discussion. + +## 👩‍💻 ~~Contribute code~~ +Code contributions are **not** welcomed. If you really want to, **ask me** first. + +Hard forks and alterations of Unitto are **not** welcomed. Use a "Fork" button so that commits' author is not lost. ## 🔎 Additional -

- - - -

+Terms and Conditions: https://sadellie.github.io/unitto/terms -Unitto - Calculate and convert, but better. | Product Hunt - -Terms and Conditions, Privacy Policy, Press Kit and contact links: -https://sadellie.github.io/unitto/ - -## 🤓 Technical details -- App is written in Compose -- Multi-module architecture -- Convention plugins for modules \ No newline at end of file +Privacy Policy: https://sadellie.github.io/unitto/privacy diff --git a/content/appGallery.png b/content/appGallery.png deleted file mode 100644 index 9980f49d3c6ed79dce4e998ba316447d83faa3de..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12212 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%0Do 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 SEC8N5!QkoY=d#Wzp$P!P-7xk5 diff --git a/content/poeditor.png b/content/poeditor.png deleted file mode 100644 index bc22c0d1888a3ce68082da5b00969f6d41262bfb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 11483 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 diff --git a/content/progress.png b/content/progress.png deleted file mode 100644 index 7a6c4e9b8a826d89d1915c2beef6202726592347..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 56445 zcmY&qGm?)$o4ujhKj8tADr(6Q5jKp+N9joU^b5C!lp*pr3|_`*Hu zb{hCX`%uHu7X+e=J^2Inxc?4!a*3~z`b|)I--T7+9~3Ssx+)-0RRTT!E+q&w#iV&# z#Uu#4L7)vaaZV>Cz!6#+WV9ukJUrSr>AMj*5oBcyMT*l=&mAE*MCI&MJq{9nyqFy4 za@i>GZreakE&IkVKUqUkJz=~_!~2-=rW|cpl%n%v*efT^K;E+Ay>V^T zTlrEH9FN;Fh^w-5J3(!yI}{hIM{7HV9F2hM|NIb`tyPMR`FuGOC{LQ0ZKpQ^PsM@A zNw-@)(U8hMYHC~$>rAd8^eHXq&ktxAr7-Cv@wS46&THL$HvHUP;&NJ?snKGEnO#a7 z&nU;Sl_964UT-kcIJF{3z7lB;hxu26%`31o7+3$H>=#D zCyREfz!;=r1pi!|$WOlOVgkK_NjMcsOp8lPJrb5-F4q-A{jy-jzX!Ajr|3-)<+&@2zqG{hGn67dgW^C`I8!7V&4v$>@P)l+r#{ zkkztSu}w4kaVupC_-FL?g}>{&8QpU4Bdjj(g=F$-AJqb}2$$Of)eLP2Zj`km$h3wy zg>|cM&cyupJZ|2E!;S}*;i1YnRO6ehgO?PR;r59a(BY#?VXr{{mVYksn3Y1`+y^$q zu7U625X%2)C(0kJage74|7aWUNAh~Xd!J@S@#m6-@+HvkH$%fqOD;s*$JXjTIde1& z{C=nWds+~?z?~q4oRs)>vRxb1Ki9>|0~bNUh+VXJVj_OM6c-a+6(NvS{fq&e`S1R1 z3@;D9(n#X)e9;l4+XPJw|CTf^6e*nLa6#rT6G+Mts8 zF%4~J`A4dw!}3Rr$L0e5`+VhOvDZSo_p$2jr|_$L`eKjr{4z>YbvBhq_t{ptg*uG8 zt)(ay*Gon)-&EJFTvA(@_G7@J}E;Pq()A0(sxpTPIfR2?`>f;Hi71p zX|mH0`w?FMy2*Lk2sz#^@=)SuqYwPwGwR}mud<+@6`Hsy=(@l7_YR)1=)g(#@dq!S zt?rFWk5zo?;u&A2ow9qHeB3BICT_MHC2Mp} z#MJSB&-VsZ7;!Uqav6pePN2g>I6W;hXNbk-eM*u%x2UVCvN@;FSQ+-N>~`f`iYT!E z^!g6s_AP|WVdeCN|1F;X9uFp_A1eMzG*3G52>#%520N^51>{4Lnb|`qBSmK>Hqtn# z3@Pm?HSoXo)EDij3mEd+?f8hsk+2&YWPW=E?6l8xCcDY`7qYwW{@d>k960=BhbWv> zz-c?bez+a7BEdwV!UnsitZ}?@h_$w4t&ANxA_xUPUVeN#f-^CDUOx5D8*`vHVOBqm z8D2(FB_8LlRrD-F>PhbIMz6z&gXq3{@Rz-iN?v<;*J6buz_5&8dPtMRvb)}+WU}+C(wPvVG_yYXOhdFng z<^wA3C?p9*JTy`)9{ONia2<{`UMGuWd<#kUT*Rz$;Nzb$+67 zfM>!!9KUXfTZd|y-Am^eO7zK)G100)TKR$MN%C)AFA%FLT^sd*S91AtNHs5yGUK$Q zv~k06!;8~?rS$yZ?lt|(0ujvr9a+E5*%r_hno~Zex109U7I(i0_bfEX?k&u46D*k6 zG(LC_{@SBPW08&D#^3it+0l}xIa%2HRTzf&@*ADV6cR zhL|75`S!b(XS(u#x9#f3mSt!ww7X^&*;IYglQa%5s_5qqEPo3a5ITJ$0}7%nDqFc&DBaL%;CQJL!&p zPt7%nz=$c|^#oHRo|#wklcf|kW)dB^&yC+QtvnuC8C*($bgJ4kt-@u;Agr7#wyc~< zDdAQovl+onfgCYqsMQW>Ub&%3YQRsG|ep`I> zb(u6>9M0!DRC7y#q7|rVHnI$xW7{CynNlC z(4Cplla>)~r-|DRLJL+OJ~1`2HGLmN@aJw4USv-5Swc%Xt$GoE><7(23w90@?-<)2U9+Nyl|_{%;PgI+B@TDN7@i$Fq!r9XV%9{#sv!k_@Pjd!by78o+G#3kIldE)(@kL`@ z@(&G2Ys#300q|?CL!G$N^k3)xf~99S)zJ{8sroWiG-P1I3rC4EV*!PRee2!ssUV`Q zy*V`M^~cPM>Nn-06gmm0qelI#5P@$=v)n$UZN0r726JqqA3A|9u)@=FNW3f|ebx@R zi>9e#&Cc6(zVHhA7q|aHg-dKIF8qfp8wuq^^m>0C`l9Jz|24AEeNYSXy@`n#dn+u( z4%<+&^qguj;7#4H<#3mj1SMDuP`KoT*by%mG}hZ_nE&CaS1JOqcBoxBf?(M>-edGjB| zOp(Uw<9r6&fWneH1b;WCF97n3JMeY1sRN$_B^8`A0RKG1-bEYb+qlZ?~0(Suk()QV#6nh z#bK1nq~WODX|F(!VrWZH@m*XR#@=T)Gc&<04X)H!1&tjJqm5b`%=xdhfhY^34Mn1_(Tc|+$0onn&fGZtJiiz21s|_sv+j=mDS>8!icqf z?s@%4>-5(i6~oKhhYb(u`LAc+qj^I2@1^IA7+;Ex9&1GN4cK9bjceBkUtaud;#MQp zGmvEmX*B>3E>U;#vro=Nxh}a-BCGDcyx2iSDI&R-t1lsFgi>z81?V41xagn?`r4wy zaScrBsr(#}3FuE-zIjeSjjTni)}~fD{NPyK^aC^{ze2>zh}x-PeiK3&zE$Dglb7@U z+Lp>pnxwoLSPF~hSg3aGqavxlc(E91r^N47X3f_H5+W4lV@w@4{IK}5&Ng)_Jm(Ey zCA9zgD?jg*)^nPWf@0Xaxw^tTHX9MjFsD|w>iyA0AzJjvp7sLe{CbENp{kD7slQ{b zd*tZT)9k(Kt=U+*9b~0e6r5F+@albZl!q2J1aqpJ!Iyp10!f^hJ709fEqWyhBjc6& z3yZzxGRD02C?|?eS`GIrc}bRz8QiI1iO+*;gxS0Z|7*ett${M&g<=P33mBZo(kX2of86PuG>+CvGqzmBwjnAE+&Plon2zi#HXJ{u2$ zqG!zJQG7|;1H#%yEWKwmUx+1l+-S+Q3;7s$sD41IU+h`1#$tUY&rJ%_YP87p0pOYj^5BeEHoE<2(gIh18 z(I}gu=)ebd272^|>9;Auw|1Y>6-z`Zz$AF|$lAy*kCEh}6I_q@eL7-Si+Z9NLb1-wK~do6+>E zc;O^?AK!5);t%ZPr$J+L=b&BCF`Aiot28nEWT6DJNQM@-Ic1qdpWNJF*XgUHKYDlI zUa2dFI~eGPn^E!@)q__p={BOJ;GKn}4(_zg#(=Krz?pm@5}KIH#-n_;g|jgLvE9hP zPqZw~Oa`i27xA4MreQ_rUpdntOXOS;cA-|&2&k+u%=azVi-G%FRVL7_lo>4+8wy3; z&K|Q9>{!>=uu;~p6-v3&Z z;Om=@R(>BEd?d^Uy;thDKBn6q$0ZzgeCR()mAIsx(mef0yJ4r|bOq4!1}C!rHvGgJ zWH1s1=`x!a1!*;>Q20W)C%{ZC1k$S_^uSP=_FCS~=(b7M*}V=6C%cO@&7fu)Y;(wPR)OW?=gZJk8rw_r`-MNOB8A0BO1W zSN-Nd3q%y%=_E;V?58lI*$UeRuU)WqY9orydB%tBd}n}38Y4rZKY+~ua{%>^c`$)F5SU&leC1e9XqZ86doR0E|H z_7XulpHKCW-URz=Z#(U$vG#05h>c%B(cdH5<~+LxUxQjxQR7}%XJ4_8WMBrprV01_ zs>Dc-_GKMU)2gx4hmbAq!F+;KU?_0Y@TF&7W2!kEQoGi7O)!BQGou&Kx!p)VZ1s{5 z)C5fMh(2SjJJ}A?|l^V@36;_N)1*BMgrisRHn9t(WI6o8zzY;Sh4Rtg|~ro=Xcm!k$Lql zsFnc$R>8vqVjukYXlR3U8)J6sMIHDqcM+qhUz>tG2mf+4wpD5&j*<~dP0*aezcxF^ zQ#dw$9-1NK#8O+#@Mwf zxHC)m&LQwYy&(B87t*p#^o>=(^~8Mh;Mv=r5H$u^Z4R(~1{d_rNz_9W{}9&=<%?K+ zE?AckAEcGv?pSs+zD-3hJHfG=$|U3+Pi7dgUo^x}lW?^h!W<^GCt3e+&S|3Ay>N{Z zO`q}f!fA)ccG8Ru>*uziykw;4elO5r2Bv3w(h-$XTC!w(=RpI@m z4*`VMZx?sa=o&BFN;2*jNkXi-)i3uon#__q=Mz3PK*&g?yPcL#{y8j^EEAefz~McaKsKUlNwW$NSwkSCO0TlyjHZnoq5! zz0Uu>44G69cBBj=djHqsM545yuYalR!b@9`v?KIpDtj%TXKpbG$AR5-X>?QjNl#3f zEC}U%t;k(03GE|*9R~ntCMxH6+qK!&(oz6|-{oa~ko(2%?j3auDJVhy zniAB@ULdM{LrDfauKPB$*y9uw-rNwOU8ryVj!6HMAtl57^CbXwEjRRYw<-w{i}359 zR;4H+An+s}eYx@%dHaFLNGHmwf!^VB7{9@w9elTHu_~f~>5EtqmGutn>w1OS@i!VW zSU=XWP2T(C;d3ba(fd|Z-Jqir{#BLM5^3)nqzkbqE{$3i&7Dg)URYY_erx#w*pCoz zatdPR6~(-mipF%{RKui#OLxsb%)xp7zYflHhN6BM*PjY-J@pF`=Lkv#&vlLaXzFd&cu_ij@<~-}e%YzlK~3 zwP;p2#``jzO9*xODK)V>czu+7w)70&C?T7w?%|LVpNfQ9ga7cb2lRsR7JN&4_!36E z1j_3VcEL$9y4XYh0_uzQX0_g$te;KiZi5`bn^FhD`{B-(vi8YyHRmI4RYTy-3_5VA z*iet{moIchcFa7ouP(XM(2C# zR`XU4`Tw+E?|%YaqVcKgh*`==m3KqS+zbYavks$l4_l7Y%Bms~-}6b-tU)k+O$PY> z=BXXRrT8-yXYh?z$F?KCAsH{P0&JR>uMYWeh_c!b>mzGrbdeC(!u>pA&e)Fp18p|P zzQ(GU<(15z?YP$E1o|*yEaVM{>7+&^ib0}g28q9HZmyxuFlV_1^J%eWjjM`r@ND!SFi@mTe%t6WzGMo4Q0 zA9m?S#lW^{x}inK^}3JPO2b{`qTm$qJ7v=>MvS8W}D7MSxJgS*=Sfjkv98 zHt?t;sL>JZQ4BieCyl-J5U8hLWxXDy_(zY^D5O$?Q4l6tn{Spzx`ND3UKa%Y+L}`3-bK?_ zXYjWuEAbN~w}a35J$5FXL_2^0$l zGb}uKuIt_ev>X-YGTfo(kDuQaqSC0azn&P9$3EzY&A+rw5MSzY`@uKbMDo}G*2CE%L-Pdr2xpVN$iIEjZ z3kvGldtU&Xz*+$32xIc2AQkkJoNm>p&yr@%xv;~}&jq_ke$^MO`XKg$@Rz43CLVwF z&0_6knNzq$M!rD5ZP+6OdP(_q>c*JQu&RkJmVs#)MOzxFe(rgoTXtI3rEuG;-NwVjfVLx#iZ{Tn8d z98qyU^H*d3$8&zWekh~`C38qfPHLBW5s6UU&Pj@C5oK{w`H>HfAb8*d{NgDLMzb2yqMSk;` z4BSj5*2@CKRi=1-UM(TX(zhaM6P3xkx7=qu209{nAziD_o$6t&jVQIw7~xjls?c78 z@L*sby3gO<&yD-~{;RF*zNzxl(+*56bb4jfF{Fhmj&49(AP$fg? zl03heO7`&AaT^E9R+0?{mD_-e3D{5%gweiGIkWP{7RFB@_D<<*%`6mXIryo%6&fN( z6hrs2of+(oHX8QvW7jfiO4x&%bjho!P_TZG&HItfKZQ7%tdoFbzL3L?ca}gW&>$K| z<3`lPp{Ou)=T>z?M z7{=vfsWxM(T$!LP<%9K{Fr_(&OYCxBy8HNRXAk-1;Au$d!St@$7-1se~7+$x~ih^_IKLQU~&LSJK!MYKFFL zw|Tp0_r1eJc%s57@GDCR+1#c?hBwy{x0Ok6FL={a1O$CPv$EO}>p#dP*sZv`#ty$A zx1r;V8z$EQfn>%h|Eh5OcO&{?8V@5DsHgVq1Z?!jRNq5J>MzLoWHvyn^nQdxL|kPvEa@PZzDkDCM>fg`rPu4EO;vC zG_6u-`EPQ_O?srFE)T?1{g0YcITHlz)I-8>LBvI>l41PllS8$|AimPPd+T-`qA>ts&pIPC2!% zScJftsgSj}4GA)y>KylX{`q=R7L1?z?K>A1w*9#yP*AZO5d`m~_xoFHsoQ!7J)LBM zU;650W_W5*K_Bkia82fCimm>yc9jDSZ%{MKhcvcw=v%8=gH&rMbh6k|KFzG0gmLe2 z1k-=E`)LHuw)|PuFrR!MU~$O?aHO>l%!E|(QhVTNyWUy6`}mDa7tn4NK5?*WG--VF zZ{^JRtv1xD1!PyeltK_Mtg~iXa8*NgOmQ~3Fg@v z0dS2Ay<1i?3dDW(f=)aO zYY(5kLV+iD7F>L9*|tQC`+Ov!M;FQEh?`dz zP+s%gKOelXqrSN6Lb?o@#zf$_lF1CfJm^6ABmD*cR<1u>={CooKn1e{5U2?F8>ooA zi4Zi^)2^M#05gY`Fo3~oN>-foG&GGVjN1q&pgqnOs#t6nSp2g9DRFpdl^ zqpq(sZGY;AS^;>f_^R{CE?TotmiPvX2gqBcNa{$~CT=}OEw%wa1-L{(E-C$2;n2FE z9F#O1ionsc%jw^x<{)ph-G@bbV zy#%9a!sj`iCsmSQtfwWxyO_Ricp7z@p;gVgRR?7+zPMhi^`ua1H)_o3XOQPnMPI%x zDA6|;db2@XS39vV#723GH1*sByOFKV&Us$|@W&a%u|MuzkoCf znB5<6XV?PaFsbYY+d+%sRFIjnuX>SEX*}g)HxO%yJGIagM7vv=?%BZ>h7@W)Y`uR4 z{mX+8|2SVI*vo@Pp8~rV{m=`Ygu#XFHOmiiJV@8ui*GH>JENY|_B=T&C>%@zP6guF z1}JYvLFCl$nKRg+L*OF~FS`MyoTXWGrTWB3=_iYopZ&lrI9M#?GacS=gj5z0*-8o~70cP>f9RgNrDSclJJd=*{$pQzn-c?0doDKOLQ>vijmaj+m+0M$7=EsP)qH%EEJF@O5AQR&e?g>EVTe=F&21|aN`q675F$Z=U9m9dB@oUV${ z;Ce}OidjDUk((fG;z<)dIy#%s^yzv-+w(WS4|{P{82SkH`C(E6!w`((sAwNb*kGEd z3dv{)>s$O_!+@8c|F4wz(S#CjMUmeat6<=nP(^1JIT9QwnFBgE${YekW&urmQu7jp zDKmqq(R{@!x>>_*)@xY0S#)sh8xU>yIC+Xu3D!xNW?y%S@(w@0=t70jpv%UhB=r@F zg-D*mndARqmw+yMrykiz$$Yy?nGT$4c0-j~H+#Hrai~lFAPr(Oo>F8tT}!!CI=*CQ z#FC5t{@g*(sK%$QD{N|%<1g;(#nHFMfInJu~)8+m@j_;Iu&?JM@wQ z%mi*&`m}zBwYC2ReP8A3~G6^VTToe&{wyb0YTa?mO4v1=0Ct4VJgk4tWjR z*yXqDZs>#>vU=#4`nB>G_kZc>{jMBV*`Y+@ud7{^@{$E!wWY+M!d+b8?KtF^+31j- zF%+G-ow#TiwR0QOK$BI(NJ0d*k-oU`(LT(>9=3!`w$ZV|*OpKVdtRv=a$44 zaXo=m>SG9(gb>k@Uq)V?D;RIcmSs0^=OBhe+C-Ky*Io@Hz6yB%bsc{H_#Z#L4Th5{ zXO-EQFDKeE*{D_t-^B&!OQmYx;t3`*7TlpG*Rt}WEK(neDmGeE^j!6hn=?;%633GS zc$Oy)Bi_D|!i->(SCn_asb|*GB%T#nZ%9y}AQfUD?6DCMr`E)+uyZ99{L(dVnw%*P zY4&^K;feV@8Jh2tXzDf#-o#O~@&|7O>R;hd1`Ljcxnh*(UDg3w1b)SRP`)N3@6o&M zEqKu}rF`A*dcfFqdOs~G@_n+HSO8J$q8q{AXvW|@BG`uQGKlBy*4Bq~iU>IJvW43O zT|`wxkci2Z*k@b*XHGmPFRur5yOWJ5;Ni({ol5$+Vq5q=dS50hJ7mKW|c`}0J1N@fr8NLE%0KPC7wqyYf zBC$>d$B2X+>aXFB-&_}x2Z@krFxF9?IBkj_;|{c+5~BY2oQYyI3q;D+;ZY0d5PN4J z(EH=u0&5b}vk|neA4Y*fbnR{qMFE+`FCZpJ^~i_mqGo=G&EqqgE@RWw2dfb6zlpqiyb&Oxl=|{2O_j_6pVMZ7^ zPfueBi|eq;52?T;E>Pg=3hn?$LSjrDs3zI~CXCoegY6AP8d1!Q~21N!hf#{ZwMuJHUIa#HQ)Cm!+NjU)G>ybNSSCNrQ9P~$b$Iu zO0QEq{!MnA?+(V*i}`gnr1isk#765;v3KAur8$yfhr2UfTd_8d7rOzlVF1ioq{T+^)JYk(C>2jMaRGFnq~MBcsaj4^eA_kbex&KvpHAdvE2ZGz3jkuFkx zecFY_lPzvStwK%W(^J_|@P$X;;7YLLzp7kv+hsV?Q1gk%>sFY%LCvj$@{e2RqhWE` zZk~l4a%1J3`E*RwdeQM)dcTc)EZp*!1{wlK2L7bjnU?BEn>`VGHM})r-DU$bcBJ`< z8G7*Y_9L4c-pSsd4Y-2mc9xc4o;ZBc{cgSWLV=@gy8lYKCY@T2;Tr1tpTLDmF;e@L z&w~|*7m#`@)dds7?FN4Rx~W~EkF%sH5e+WMOK z!9i{%*b!mp^GReS>hABzN_qT2587EnT-OLTV0uU&ZuPq!^RpH0=lXO%hdJ>qmOm(U z`i63rU>fMNP%d?r2bf-59yr-vkH5tYU&92%oB`hTY6=DXL@RvjIqdRDp5esaz9xw* zUcvN#2MoD_W|~Ce{M;oA{gV>dMQHpP{#S_5NCp8>x2)jS2fSq}i>I%73P#B~B>Zfb zTOIrUDEnTZ2v0I7NWV=htkQNPXMyMi<7s_&+(w%U-0vu)J)Ti8@+0@-?gKkp?CVmC z&qiTktAY1A5BAE_COz-iPc<6>>4K82q|;UTCHBO1SC?+hgEVcX!`9lY!8}b9N6fe%}@r6F_4jF(`}f3 zG)-pq5!Gh5WnnLa1mnjpVt}G6okpP?0SIv@01hhbwHdGBbm_g!8HhZD4$J^`b~VCC z?2VGKKPix&f_PtO0#MyvOd(6QSvUR&@5wDZm)SSD#a8F~iYlDQcWq2|Hl(uo9(8I$ zb^TpywaP}iaH$DxLQ5;!jXze2745jPN8?xFNcMG^^@~ZiV=01zQM16Fmg!^yQm}!f zRhNuT0F=Mv`H`DUUPMgbP*j^{Z6*Pspk9sB3af5`FyOdOqxx{MR9pYrUvi=oWn)P; z6)P2~%$4SP5q64Z>O5YMB_M%%EchgML5TpDbVt6D5F!yXW&-$+F#Fqr&D^%-Vhzy% z<>~p}m8Q-GGnszM-(uXa!1o03JE&mw6SBK4;@n24TMqVs+AMT+mZ~_*WK@}nm9rI! zdExb!dh4Xksn^6TJFrk?d~Qg08Fh|(pq9lZ;=4aWfehVayJD~TY#SwK6FGoZ(I zYjW*4Q!}%!Cn=X2g)rMmLpFdSSQB?JAAW`{yy5p@YhwdybWHQ~!k6_}U8hsWFWynK z0Fx?OD)xerRJ0AFnxLZDV>=7Z1Hv>Kt4))wgp}U)m-&tikyXhJ8S(ciTR`XlXzzl`_~n3s`^R=K#T(+PfsPz zF021U+HH&M*oG$s6vM3V;NE2-=AfQB!xqTp^v^{NxZHW;CN?X;VRQ# zl2Atju(ew#PoH5$0UHN_F^n1jPXOiV{hgJxWC1~T5i7_ewoyWCt^j)GQ#!O?08Rq> zmDBOoyK2XAvEUUVbzXI~(N+VAi|?q%G!oeb^z4yu9xHo++@-Xal@dX9j}AA?SRi^} zo)bgE8|-T)yU=xtXibh1YxgsiGTC)f((IBztmt(%3M-5<(XVhDtX(-<;*|FzglL=F z?L~zHy4u&CZ}NL&=CASZBT;)j8C?MV6bgosrtV+|H{5%8{g{}cG^1o?wi5;M})UbZ!k9f_MM?Qte6 z#VLvkPNHQllzbSY2OQJqy?Qz*5db{PlpLH2R3v4)T9Zm3XrMsDwx?iZ(dM0T;kq z?|Dj!PSX+d91(l zEn6TvfpAMvN^tzDRygC)f!5}##kblo#sW2SI{vJV;JqkWttXC@MIblw8d^S4b#%bT zJc}9BN6m70OjTHl$u$tOtrRADoI8BG5FVyx8a_y$T}Jg#F5-Z^BD1_I{Nz*lkWhqW zrdFeGS0V2E{#3Mt*+=lljJlt_aIrf#mGH_fh_ z`RJGns&pt|04LX{%~(hey#76iL{mlAv=}>JN?c5<=mI=k7ZWdEr|2Y*1rROK!a>r- zV1}kJUaZ?g?+}FV4GAK8*?oc z+oN>IRG1Svk{NUUNFV%aJQ%2CVnq{?trS;FmNt^Yum})=-dnc1$s8ttl$hJf(XcG9 znz@{}u@^3;XS7*tcpX%--DBeQd<3`7e|e`QZRR8TG;@<8;f?y@T(Pbql@k6acPSe( zINC( zi&GSvZw^KR!i}P_d5`t|UQv`u*M;#fVy%JeHV56~}B50+wVA%UoR4e7|zqEOxE+S;#?fg>6$aU9bh z#l)nO$vj%G{)uH?kLZO}!%7#ZsjdR1{6b^Cf5(m7#uQNIiMPuPTB9KXfr}eU1PHYw z7|6|?Dakt?s_B}XBx{`-?iVw(>(5lQ4sJYyCZ`C1S_fq`A%l(EI@Az{a!>l^A%5Ge z)X3e=?Tq}+M(ZY@oI4BC-($I{=Ig{8NVdVV8IUd@nKR^dw4VS*uuNsNyy?9Wf-ssx-WiCRSV+^AZ_;Xz_p?8- zd#jaMi*|34p{B=e{03HBQ*ZWXAdHb5JV&IdklWI$AIXGJK@4v##%WdtMz+h4Wu{D? z6y8NYnrq5e8`$uCD<^LGYk7RhPPp3W_ygiHRYL;u&9SO=(}xLRnNwO*U1T>7@2}O_ z|Kik|NhkxFbqZTpJ6I{kNuP}ImPLKz?N{ds7q&JNr$Sj3*^d}4A#g`*WpH3nO!QP+i7jilYPtVo`EHt~a+( zW5G7*OKeK2z^t`eqKxOhg?S0FApzEed7-htb;a9@=9wBy*sQ{&RS_UIs`qDOVi`~9 z_b#F1-;mqg?3*`$I4322o-}$bs|Z@`p@fO`NoZh@lB4h=p4SP(2I* zEZ`)HS5uy>frW!%z!(7X{)-ovaEo)XTQ51LDqV9oetg!RPyj{2ilF82z%ncHlO$q$ z99v21#b4*R6tY7}RYUn_LDJGSMnSXmSA>Mp=R5SzkFV!_`eYU+uBd3UyX*5tjNB*B z3pL{qQ(b*_E${|yR)0(6AQlq1JtHh#(^2$t{we5bbwH%x2;S(lE9i=xPzHDZPlyZa; z;UV#t;V^{-qDSP?J7bRiGbJO`Yl{}s-c`|8_S6#tk9FyRX*C0E3<-run532Kv49F6 ztMVHVkp7;z!iuW1D0tvJL-j&zVd^e9EtulaAd2TmJ%cpq*q)*6;Pp@rD%{DXq-P_A$z5-9LU_wy-$* zj(NC|NbyS6_xXIkU}{cIZngiq=R9`vK8Cah=@v7$HcZ@yW^ z08Bj8lqbd8h=D7^2`tQ)EhrMs;hQpe*`dZgKilgJ|eP zT$ag2Ew$DY7R9>F^gtDSv)L0^b?{(n%U}1)n+=|bZ};VGe7uhEXO0A)Pa#DmSR}*~ zJ!F4T7b}#40+eqd8@G_=n!pS&^kGqP)tf#yuNy#N$=PQ8b&B(xK?rrh^BY|!I5tJy zcnsoB?Zcb#{S~k8z5a9tz%yIuf080*K;UhU%Y2_KaDKfol}&L!4!&V@%f#DZ$`%cg zEe-C{@UlMLfB?{DQbaoDIIDFb&rxMpH@J%@{XLG~;k=I13)< zCLZO`;ogG4JVYh=R@mhmwn1g+skyKwt`I!ZLC$suw4Pfw%N=61!Zps-h{0Ff?lI%z zt?^s!@hI;q0;Z5QR}eQDhgiCox4AthAs6Ul5?H$2-EA|zLBlF#kHjlV6DLUrg2|U( zHQV$qk9l7MddKc~n43~yso)+B4SG*wvSoR?*4j(D-$orx4JUb`!Y7uF`F};jK+a^z zAhXu>`+-ti2ok_+#h`Y0`q2f*_bEUZ(}g3;=eOI#!Eof+W6FprQg>#Y&sWp%j%XMf z4+oTa!%=EeQ&S-3rRCon1K&mSu{l}YtUS#_fI0gQhj}*cTpHBOFa%}0v4=anOc^XT z?oTny?R&POswzRoE$lQF60n`}+E~fVq}btC`Wup^!+S53tplscSM_(!c3&V!KZRHR z*xu8GFNs;KoL7>_)fSN(K|L)D0SWu2uDj9PKQheJ_I{?Zxl=W}XjFcUb}uc=??*aZ zAi0Yo<7sYZC?PL8w8Z->{A#L7hC?l|tM4CyXgWoU~s)J-4{_s1- zIm*z#wrA2y=8EG*@G7q8L$g3I`Eki|dbk!mb~)hap?8Xn{bacrj5SL#85CeEoMMr{ zn)POSp1E6k{7!i&J|OlmaQ?<)LHl0C(;c4SdoJ-ROOGV%E#SSEh5AXIQRyKj{k;XZTa3j; zk|tqw%5$pKM*+G+!%^3QLzM~IR>P*iU^s|F8AyIrM~#s^G8u3ulm0Zr?K?7cDE!eZ z=oX+43oM0Sb#1bpWQ=-L1e(Qm{7}DYiv!}Ko%w~$5baL%-LE6hp#0>$1reSD&P*Wp zv5%(=sFn^guzSlNL%skpfRe7g@X#eqEczB-Y~|e-n@{gtLKk+>OC3L7-mcZoa&s6b z?wuwyGWKTo-@1Ph1f~v;w~<$ts<4T~!BQ&dStv}_Z^6l#u&*|Eg)D>=zw! zmwq%->p<5u@89%=%~OGTI#P5@BM6C7|{g=*5I#MFhg zewj=okhema$m=-gNmB*d79fLZdPtTW9r0^x*qH~@fvC+=V332xo)N##kRlf&808lQ zTJDo4srtWyP=2Q@>zw>m04m@u zj8J@-h|sy4Ig|{@hdJA3Xp-fxW|nb7ta>3Szd|fSG6sJ4?aQq92*M@iG-iZ&i@*7I z=2{>;1PlsXsDQp-bmD5BhL&k0Et2MYyA(njWqMsx>rpOfZd#2d-h-zLcE5{}8H#IS zYx>OyFxYC-8t)9&-8WK*wbULB!`Iv0raH^Xv))1Z|CoBqxG2B=3wMB_l@vjckW>&* zLYkqZq+5_sR1l;)2M|Ogh7eHcMjDiEq!p3w?jCxEm^mAN&;OkBM&H3_hWp<88*5!l zW@CS}uG0BOcJ?>)+`S3dZfGy;{vCS*OrP6;Fcsu^N|_RZ>AA!QO{?&UY@9CCSKFqs z#j4zL>$Gqaiyza+W9D{xTSLZ@q1KHDWhFoKCo1dTQ!a;a;UJUE&bF{0Xwp@Q2;GD3 zJrsJ@?oz*!0FN@kYcfI@HW1A%9aw5HD%5v{m~bK|AAaBpyY}@W672G;wfr%J@cV)o zuY_q^y-RXtsZ4wGi|_FGu~~mp)2^|!ow>YSml@y<=5hF9#ZI8uZO3PTy)z;8)I6iF zniRdu0QdL<{^#TNtn;m3NLLMgXxf9zKHFIL>dm2-JTO*xRiG}i@Ed^{k``&qMfL9q zs%kZJx|%77oP^iY^66BExL**0Q~SYPXlXcK45WqF_pYD!_nZyxP^VHzcH^JZ6?*5g!@3J;O8%Y;u=#UrpI-jZuco^1o`_vs^jedn?lsP#$9NunU(H z_uqGMB3p+2O|ALup@05vQu-1M82AI&A)j#UDc_j7hCw0Rx*4DQE|kYu_)A}Tj4W|& z_kXN@;%}togL3R*PK^oL{$}J98a$f~JCXcjo-IHNPF~-n9DaF56VUenwDhojIFp8bee%&%sJ0<1(nJ8#(JL z6@ z=No`NkhG99grgG09O`T-W&@?3QBon+D6SjjWythd&uLREg$zf9E;LB~@;x1!q11rLx<#Z zn7oj4y$)v6OUfg*Xr*X!eykMito#|d7nWl#@fNLFHt<^;W(Q%5)I3&xu72h-J*@3} zXsXe-W=}t~5;Y8?5)FiVt=Q^3GiJS-+>c^(vzWerG*Vq6DW2~Rvp{<-wiOGjM-tr z-;m2!A-A@x@2l|8K(_W>Wt)uWF9u>9ym5+*D-~@b=VrbPv#_IEKkzfMAPr|Ri>z7R zFu5wact5=}_;DQEj`Aq0!a)`64hi#7=3XkPHTVVYE7#*Ev+I#NP2YRAKD*L3&4hCi zJWD(a{l4Dh6A=I}dIki&x1@#4h4+jx+i;ZzS>aMEo7_j=M0_zJsr(W-VD%4NZm-h0 zl+;Pfap`e4>EF>t?Jix7xU~Ar>$aPL)~op$tW@RJFpR5O{{=+__KDSRN4Ldy@oodJ z?n4&*t@IuYpfAYb7Y-biRg*X=?BtIh^ZNNLBv5#y%6tC&5#hdv;Pz4fOlC8jPq`@M z$xE<&?BHaA+M?0Oe(#bJX~0G+A!@eIsZ&aNas5eRQ;{1YNxagn5^Kkn@dIV3S-3&7 zu$3C^X>qI|ZG01psV<1a7Q&4sC+44h89k0DoIt-vT^wJ&t{Q&WU$F^mX)}WRf z9qK@~>DU^H`u4`6IrYwrIyoS9ElYdO$b=G8yB!}BuxC0(FPF=){{-hel5 z<~*clpj0XfeKm}uINF10<#Pj%>x+oFq^+j%KbZ$G-|+|WbnU0pox64G0>qHy^%t)6 z7k^Bu70Cj$i`($Iid!U~G6|oVY?|lYz5ioOyFoK;oSMqCn6=vfGQnVEdLS#MG`pJ6 z_jti{St;eX*qxc$>~iduPzaY>Z||fgBv`~}D=And<7B+i$DweECcL8+b3QYy z3cnv<09yL&I%eY#L9T@Zq#H)F@{UDOBaDW7@?6U9X>d`xp3@1O^FGYUGqaydmUe$8ZKlWddsC(`?13w`vm*Z%UfK<+qa^h&7hyGocpqxlPB@=+g(B24%o#4gWEMds_N;B{zcC96XvGZ zu^XPx)7qFs7N*P9WvfG^$cQ0EIosP#%DBlMvqqJra&Kr#nbi=n}y zvIQvin_4~Q-UXI$Y9^ZDG?CqB=px6oGk znL=9{8Y%OYp%#drmwzSyZi+uR)eo2py?66=E9H+WFe}U*74 zYEfp-QxpCB$!b~kCz%osKX!VW_GXz?8a@l#D(V=dY4_Z}w)N2==*yaLrLc`JW0bjx zEPGIfkvHA;#9?!R>I_p?r0_dVZ##S`PqKjq%!1{*>mMV2iP^e28K3M>sp_xL6zuH4 zzQ9-{byuO3edEex&G8>=TZAqDMp*mPffy=F#5rv_Y4&Aq1M^=B$l#s_|6`NhBWabIco<-hbEVD8u{|&ih&xrE+EMXbn0j1oFS3FW{YG9o$#3LYf3zO+ zjoXt2$o{-MyAW>*PBIqe1_l>DAV!Fn8~#aA8o}sk{_>+U_O5IYAU8%woSIZe$q@(K z+7?zUGkvZ%nWX|YY@$dt+Q?U775~F5b`Uv696zg&e95%6 z|J6>fHP5x;%gMm5)8ofVBc94!C!*|k9EsJ56e>t8BAbuKu-d8+x#Y|?-5ia@;xo^M zvF05>I71sIR4YRu-RD;=P?KU7?AbIyE0o@S1ZCS{=Xl^hfcE!t@jE&pLAmp%u8VMc z?c!sikJ<8-4olF_N5qxU@FrBo>J}0uzf0;+DmKJ`6JWw()J`nT?JFM8&z$NWQNuQR z_xRJd;INtZnby}nJSpBcD;CB@60o7j=&|?_SYXM!H+zGv?1_}yz}n$htvOUvPkFwv zcroRXtYl->mYaa9mKOd=jCWW~qxrlaBJ(V^D^2gb6Zq2DbU8H=!14&EQqzL9dm#2j zt+zoONei#*E~y&ihkSm-2|3)eyrYM^ZbS2nb5qry?e<#~MREzZ`#11C$%wLxgkraR zxUEU_1-B;}$M&I+BK(Y2$(BPq1oE|0{<@ssz|&b$)VYGm zJ-+11E~Z`x89j07Z=8N1KpLz4d2-?9kiML11hr?kXE81oW9!h!S4yAA!++_2xD$Pb zxXqMCkg$KS^Ij16Y;2ZCwAorbQ5Wi7SQEGj&scxsqZc`3RA$ND&2OPsXq006ccs-e z?K6r!0O(1moT`RI#2P7|<`jk}t2+8nvwz{&OB^VG!PPz6Z9MK;d{l`MzgH}gtFd00{F|2fyDMC8ebVx!zl?p# z)LmM2*Xo`*yk=kJ9(Q1R{kA$kcXaaJ8Q?Y!E)HmunnSY#k}76}%)h?OJJ#ho6W2I+ z()g-kpjuZ2bL*mu;06Rxi|&niGn3Eu#HuQH_At@(j`H$Aq~E)JBWl6F?giZ8n{dx8 zh2J)NL`Ot)!rls`6QY<-mq>1BR&@2wZGOG<9j4Nkgg&=}_O)mr-gno1&7bq8FjW0n z8X<~pr|q@{{UwLg=MwSLEHCroJOggYk%#B;`=0ewW+-%?InvH+GlV0^%O4=j@_aEd zijYmYY6n`b?dUxM7%a2EBlV7#w$#;w_Cucd?}7WNA0~P|leF4q-b(MlNdQsmB+=A) zN&&Xe7r=6=R{>u+%;`^4kj>ctx{6;>8F|x_dFElSjfZL1xb(gXg$-i z%FVIK&yHu|uZ~q~&;Qg9pFTh@^|@_*awc+zY%_3E&a>K|!Yy!Ha2NUp1jM~WE)`g% z*mz6gFRUMSJ$@e*`!3OY_~~55?b}%-fM_Fm!im&iQ%2~sM!v-T`t-ev%BP+XFpB-5 z3}G*XPxUN~+-eD?s7?}hX5OjGhU&ijPZfah)$!Z(rHH)H>fk^4beI1-qx<>EujS72 zDvjB_hXt30Gvb<8yBZsk^_%&V@&Ds!rZ3kk9`gY#z@c6KD%zjU6c-}X=CkgoD=*XS zL5WJDkrH`xaJE(uz!dKbJ+dCxf0cYR=Wf%GMx5P@eXfV4fYvgs@0^edK_6Gc0p-ACeU^68nAWrtm*NSvSRC8)nL>$kQK=UQFpE#Th=wq>?hme+ zQ%a;$n${?IgH6Uxl))B}Ad0;7hw+zKh^@TmJ4wC%t91s)gyVr{M;y83^in#Jn7tD# zG?%#D<_WR+FVjB%0sU5$BR%XyEHmBkbFEaqH=F**`Al3ZLYLfX#=jJfJEVmxbv$by zP12tmI+=c}iu2M#UTE?;xCxXN_()_rq)ygb4H?Pk?E?!dUE(}*dR?eC@aPL)N-0By zyu6t0$uQc-q1zq+98zSAiLFsda0*IGN+{l8XJ$G+ZbIC;uqEgvsaH`>X}4{*KE%|R zo4#n!P)12L`+li_N&(7DQk*~0#hgF^U@BP!HMaEuT42-P*}8QqVhGLWhNC(XjqN|W z@bjHxUT(_)&m5Jp70}ZeLwCz?!8~Ae6QPYG_PQc82y%S2FHjjN4mw86!z9<~t)WK- z#rVo}N7s{_$e*+>noHSj`5EZyvh6RX`6K<5q6`~kkXW4vpXt$JUf8(P+o|uO3#Z^` zb{d8E7XYR#8ME}H+ybt+jhsp>LpL(-Q7X0mJTn;;P8RM@JHz?d3`+W!UjYYV>k!R= zikg{?Ks=4P{BFmI;Jy42mecY7Occv+^N^TZT>*&=!uW$00*FNpR^9Zn+%1?eTqHogt z{K@bZ-wo8b6_`FJv!Ue57}Zy3ad=~mjIax>Fx*D8Vp|(GieaFgSCTk@y8?c|5tiHe_&I0M;J;PQ9nn78*mJcGKsjXesX6HZDH)9r&Xgs{ba^Y#ggEt zymMqA34)*KZzKb#njStgE%cx~U^y^_9fF(P?;FZa+4{Kz=)`pZz@)%y!JzdsAC|N* z;+GA_fE{geLsp>&F0RAwRRc3q_cIVd3kXOmms5HRCOjT9@X}|nJ!AdLtvww<{)oLz zpbR%fRt}|;G>%j&RiUM|H*YR7z1EEJjMX}S%pikrk#i?qx23C;J}|Xldu{c|u$M*9 z=|k{58v>Tw)X79C&8o)*PJN!O%%M|=40RzMp|N|sflFy5sm9qt4pVd@8y=xQn?y)< z^msA71|yMjpWLTYMu6bS;wdrGTWN`{Omppig-8_>weDj zIc551*Gt?(zxQjw=U=d2`xydZ;!V{fN4#wE{gTU7bW+~ImR&rb;ycU3+!Pi$1oJAr zBGMg6;MY^em&9$-Ykc~#@1+p@!yK->i&SUTiSa3)*gCzZ8?78{>t(&`Y^IgPX>tdo!&)_me1I~Ni26u%63FFxxuG1mGW54p<3n@GnJ_j z(i?ur3CQmE+q)3d-rW~{(vZ(fk)<$Je(fUc zXQ`~^LGzd$kI|m1x(=s#;`R-nc6HLd=zDRT-(I$id@rF88EE7me@dGojT7Ltd03jn zR%lpiOOrki)pL`rF8ky3A|ilGilEq}W_Xm0h-e#1qY*LQmM_H#$;1;tYevZQJo9UgJVqAK_X>xVpYT2*$}V;!G)Iw;#leowjefK6k8&D} zB9X2`XB8mehDF}$34G?G_xB!`A>w?y{rQtcCr0L*l~g@?RC|V5H}Kb=%YK8ufQ;Y+ ze>%b?Jb0F$bv{Lub5k)uBj(GOd-3u5+{7OmmrNAf%Gzo$pcx-A3xEQ~LqD(U9;y4- z|2((iVq#)>_j>$QRX-++n(+x&Rswnq2lk@hP}#uXh%|2lDtQvnJmT`j+h1?y`vP^T zTTP(|F4>H32t}W}cPJj2FRBqHvbSqv5x6!XG zrL^bJosFuo3KBZFW35`MYn8XAC=Q0HL0VKXA35oV=s1{e8>`dpD(udWJ(bF_`0yTC zxdmk`hD%l(jp57SzWkHCr&!sw@L4m(JC&{LqLTp|)`u0R%r8NZT}za0eYS7oO3zwO z@c{F^05|`5Smk~86NA5vaV=W&{gM-oIyM(4vx1Bn^yp%Lr66WM?nQ(wlW&DwBs3zF z1o(9f!9;sW2@zvIqqDL<9tgZ>XXa8^4C=h&UYQl@ zwJH0o%O8P9Z~WX|>!Gk!m^}f>=5+L<1}wkO2_RRA^fk-=k+jSPK9_hMlIIu9Hxz0R z4B+B_ad~?~z!KtA@$n-oU%jS&-tXR*KIxNwsWFl*#G-||?PNrb^9$thdtCxHWLp$l zo|8WgU!CqY9xRNS+Q>cj!TGFXM~B1ONwWFYEbNq+PwQ*?!R5@N3onaR}?@5;0)e%+*V=>*S7cTa@{ehqJbtr&@ka@d!T| z^z}AlE7|}AG##L+j`frY(_X(+-SMikK5zxMDd8+e2CyZ=s4be2W&%$ot*%kew5*Z8 z(qa!GXU9gP|2c4h%uCXB4sVeY9N~G=O8+4RksVM%8hOv>@mwc*m9#Xx-CIr-X-uKy zFuCo3bkZ%lFvvyWXjyr}Qe)IUlZ0>0Jr&7=5Yv$PxRfl)_k%1EO$mb6;+iY0Y8(;;up1iG(p*rW%9kuOAza(M>(_dOmN*t0T=`G#S*`@6 z&-er7?k5NuSA}XDe3^E-hbeW*_S(Q(wZ^K3W9UR$%0z;A064MMTH+1tFV!a zSPNngpwTi7Y4>h`2d_g~VceeAW!K)pSEl?N3SFGBcf2+gW|v-c*#}Ug()ckU&rUTD z^(-8M6iqXz-~myybUQRQq>enO&@@^u6v_~Q>$6(T8@hf`Zhf}INtq_4M2g+I0A9a9 zDDyJrGb&IOCUe*yx_jQNqi>pX64u+R`s(^eLhyv3E_fJtD%qp|i5iU zLPebHD<26yaOIIGsxU>`OGIo!Yu6J=jxIUa*^Th4BMR^}M7wuwsst(E(YwkDNSt;8 zS+-<84#jvsHqSPmYW~V1M~2JfR@)o8h7aSv8jeh31=)y0q;Il&wHmebL|QU_m(dqC z2&ZO0_umJc!U9e>z;$KEV&>eiQoVEJdoYaMCVttdGRZWDeWs7O%mG~*a~(Y_i0|Ql z0`8OlKkBIb_0Oau;UH9L14n{HPS%7mP?;pRe|jq(amD4#{*g3oCija18 zo4Xwk@TgkF5^EsZJTXj)D*!iXfT1pf>l~!HpBjX8EF51@eweG zkUZlPZ}P#lxv*OlzYTf@jYPE_9IAXOwrO9dJ3Ko2$yAP&$xx**vunUoBH5l3N&Nj7 z&$IGssVVwm$!VPJZ0+T*lbL#b=fBrMWl$CtKW1ocdt>V6f3}J2TiutWL3xEX-W7om ze_*>UejkwLgAf(CS@x)N)l{|VeD)FX+X)Vh^iEfG3B!gp1ZDNZq$AS@P8B2=rwZ(J z1+D&?J@kClZ@20DE9aJrR0f&RseiE)$;>MRET{ZOzTddpvt8bqk>N>+J>*a?HVBBb zYYxaHjQ5MH9xaK_f%|J8pFLpkWyL{O#6b1fU|j04jP)KpA&PAfAAG8h_Ig;iJ<{m& ze_7EC+ z2`9Co&m}^q+YIa5ZD~%^a0j)U%tRZgx=j$YU|@o9DhW#MT_14iML(qabi`C z43!_ml*j_N5w9iA6st89qk)gL-=-y6+{Y-r)69pg(#c|yI-4lOCz(br@jYfU8Zhg$ zxcV)FEBpm2ELu3vY^F=7XU=i&zo>z}+oxXm{_oKl?2v8oO?4Wagn53oc^$v~QYF`S zg}3VL!8fE{$23J*!$94s0ovW%h__XfB$CQvn@OwNUALtEnWWWq5ml*Ob{DNm6IZO% z;NQRL1!(Jeec-0)@%W{2J5lp=Cmw)G>fi&2RYrh~ehk3O`o=UeI5p3o;#yK`ll-u(C7E+&J5WHOy25HO#X)hij%hYiTYe z>)<-DnEYvTxxa_~k9ng^i$7woyc*ZD#>|T}nTj#Z@}~W@<&@oNyx58zG@+XIP)=?c zl6caPXue#~&nz>FJpqODL%%tp?MblTyh7=S2mYVx7%ZeaJr*!n|2eGN$Qpvc%z8QJ zSAX~{HRzr<4)+$@2X&$G3m*w=bs<4M1xpQ<50obT~uwFHS*_~|3j|1-kCpSW~RGy8Pu2~h{yA;zvlK~ za@mpS?;{OiD#FH~lTNb{p;6;lKg$w#$2I9GFh+Fu8+*o@8Z^#8H<|NB(Ie;Uz-7vt zIRh?i^pRZ26s)>gd_VMeb1wjoETn1Nau7uxp6rY&fr72Thv>*1b@J5A3cT0f$jkI{ zjMd~a=}li{J-l6jwqf3YRG*MGU?28n71SB8_9t=CYF<{=k=25|4g`%|YW6Yaz&$(J z$SSE10Ut#*3IO#!<*-ry_gZh@rVqldkRbRj5+NX#hN3+YIJRdki_e?~b?@a7ZZ~Q~ z;xyCmyu@Ewid5oS!qxLW86$8^wP7^)xApVgFB zS`c9Gb=g-tGZRVeXDW>a_93xt-rWAQAGo${_L`J>QN;!kF%TK5hn_X;2dAcBQoM(? z_{E7nC@j_UfFDXZcwbVk^ZTGn3zEPg!u4%_?yA|kB1M*o^bV;12VmW32e~$^5A?3m zrg(5wssPUcqDtIgZy?*Tb|tU>2>8C*eMp0*nJ1j#YZ1?5ty@k3Ff#F~FaG2iNMZx9 z6j>E{d2wRC7(nFFdl42LRW_u{{vXpTK?zsYE=5SXwxQz&CHR(&{2#qNlbP_3zNWxW z;krxs8TtZ%R0RN(ChsfN;%bd1IDMi zDbL*Mjurw2W)FKF8GOJ+vyz0f`gqt9hdILw!_9l zqhH@T>8qYSR(v%#mroUDze}5sn!B9u&oNJH8vLup-+$jv;}6UusN(WsaSYMjG1O$Y z%~+q^)MVBqX!kYtqP`La8m9D7P*f?%*9sBDT1|8PtALfc*!ZJ)E%B`K3*d$^=haF8 zA$9K{!8pOj(97nnilnPWNtJft-hm6SEmcq!=q^SslW*k1k8Kk6{|MuyW|OlDqdu zmKY+20DYPOn@vN*xf`hZALdt%bPhc2fv(RmWo#FMDXRhFcXkwWd0VXkJb2PFSBf8Yi)YqUjlSCaFW17*@p9E_6DHI>-qWtyvL|S_g_!;XQ z2X1zYPu0uFEk0QR&kR5<;98b?>ZxF3*o98V_@<03Oc9a z%yP<1Nq6(`+WDW~(Jc_bLU}m)i(3?@^4iP#IccRH5AD{&OXNWf6q^n_wyQIq@`u(X zfd6w)L5XT^_WHiLjEK9q(t-uyR0AQEVfFnDFsqv~Y6hT&ioR3gcW^4faTtjmJF_mx z%K{!RDq<*_ZtY9_gnUV=TS(mwn40TR%S1nDZ$g4vV+9zzsILxbnO%5AlU(m6g3Ap_ zzBuux#S4b1BTtqyaKQBn>x#eIRd3woS>w2bENuEyI zeVdIk;8#B_jNLOK@z&fs0kF5+BL$PUMpLR4(RLEvD;k_K)xUE0NnJa?mYU)N$`%`a z@b=MSW(MmlRh$Mt^fEOx5Mi`?S_N9BISqP?OaYuGcd!-!IH zZfwb|Bl+EM8Rjq?y^9Q}BXxJWkNdCT)wN=z9#1VS2h)Cid-4aks@$cBW&nU$1wB=; zYN;784dW3UQl|eyeKqw<$I5y6g*^I(9ne7l0k`IRGLr5N>KTXoz5if&%2XErsJp;$ ziPPZzIo2YcEmg-v_66e8?qq?kyR1tIqhd6I1m+1pRJ^i#N65g7v=<}_va!f>Ja*#0 z+oW7*u-3~>JNwpdORa1S^%^;_$rGr-K*%gs;sJfUsg`>>K@{;myL1 z0E@`j$khS@{O$o?Z6&0+34n;gPkZ(f^SU#BGEMxL;o|B|spfV4kAklpBq;hoh6MK= z@RbvFH;CkJ|JXjP;#nxxUdp86>XtOtx$jV7$|K>vXH#+{KN%k9t#^*R#6D;;14YQQ zV^k8$o7ni+Zyo)le}`~pE;kOj_F3k}e-)qHz^UR4cZCG`-St0PTkb_(_~sCtA+UQ7 zd;0#wsIhGjHeX#v)!w%ou(e{NO4*%3SC=a9GuNk8S58-tYSe@69DD#r6gWNKEs*dv zoPqbfyCPY(%V=wtfBM7}7jfjOqW&s+^9GS<*SV4^=!F|@?1&Li-TS;|UwL4%^)2q2 zN@7uZWxD7{l8tR{HJR3q&tR0NBljd>Lhb z7d;gWG0FnIyNNnGtHWboOWrh*JJK!|5^P~Lp!7YQ9!!i}I}WLv&C}$+(hbYg4+s+sksi|u4d=@+f_l2H`9O^5i@E0PSSvlK7^h9$}Pa=%zeqA z_imj+a5L9=Hos-ZEdc|A6}hZ~wVP$iGK)o^a!rNP-2O1CYz6`7~idI@WVh8-04t>v$ujmgum z|A9t}2kOMw`?kg`pw0b$z-=T1!lz&+-itai!03&ZVOz85Qy67mv zRUbIQxW>nvV`&9YMx6s0n^#F8is`U=^rLM`fQ?%j+!%^K>w0?@`tr^R(K9|_rlp%b z(xc>8R6LO#B*?q0?f|IcrH8U#e5)jS*;7rg3ia#R*o9B=DbWfz1)tj5_+R#OowG_C zMyIB7;t{U)0_R|)Z5v7Ch8E?E`8}pp;+m1(2e`cr7Evo-W`T!1=aJS{uaV66LBnS{ z_hs2`g+l;H$C07a;|?XH*}>l4cUWBc{gG!#VcK~dN=_d$ zqC$f(5UVtrXjfX!^Mm^!iRFWxB0^mL(OXnmhaEote)U5}X8x-fXuG|M>{1%aH+G0Rg^}=S#-Y zD&hu8io0uyaZGioNCDdo*rB;mAzrowN$hFS`0HUGOp?a~fw;2oo=pbH-|L?!%H-E$ zkJ~_d1^;v&r$A`?0|bLjD6$TcXyE`Fz*}Lcf(7Cfb0yd~VU}buWRPwCM$obY6QF|9 zQGPtH6vk-&e-pQ!%LNcX=i+*mnmT9!ax!`T#g7^r)!IZl>0GJ{Mm|k6XT5dnOvl)v z`7-r3Y(KInvGE3x6Cw0m&`K@dZp!_eKLm~c?v;D;j^NgqgLZ2(qs4R2jhwNS;C+57 z9C6uW(K#aNfqBFaPWmsLu>imStuxFrcSqYtxuL~5u%L;?@gORkk(mFhy4VB-MZBb^ zD^Q1RP9h3m1>UdmOI2nixX}bOT*I@ZTN zT*0UH^+)~&&#Q7C#2<^CZ+1wVb?zf?a)2JZoAP^`-QEg(sILZ6uu9475`PU93Er#L z)mR)nkz=t9sUFdAYbYo;mTEFLX8vUZVSf=F9q2kuq6JC+G6sT6pCEqrNbtE^dBYA+ zSpt7VW9r4TV`y;!(LcAD`FbPEar1AK$9ajV%^#0!uUT=~09;t9shgtGC+}a&f_ryp z6eweh#&_-oof>>A4;?3VEH}Tx(;k;5rCfsA=T1U@j5Vt0FONgRS{(Js8Kdn9#)C&CWjZ606&o7OYTB~Zca#D@S zG;>U5!Sd(%Winat=s~VGN(QyGWO?IE`>nA$k+O?Kf7qBRuKqWx8K{0<@4^2L9(yDd ziGQI}^dbuX2i{3I2LiTXgDmwicieQz=AA#p0y6(o4D1gSdxevJFBS=I`?Gg&>U>x< zIQc;>ubhX&Cb`#ms&w$qmM@VRJVvghJFfg;^JsqYw|%rsWXl*Z&eTJK6a_%!+I;C3 z0@7AJio|q>-dg!wpI5|xAFltqJitH&BtCm2aqX=d@kMNt;M4d=oZ*R{lP+R3@VyPkpAMBtPh8JEuNT4rGu8AZ7xo+fZ)_w- zE+?Eb`aEk94(zM9#I^PmJ|`MBak@A($TkUX`4ouVZ)p$Ju*30mxV(v8j`Qmq8~EE( zkak#`!!IK8eQT(`T0G+iZs+sHqp>kNDMF#)GjR2<*eP+4j13gibJc*fvDgNj%gB!- z&?Q?R!J>!cK4ZIn&dbW>oBlJdCUFjPn|nm?V);w#JU@SN6iT$-vKm?UcB<+-+8gCr zddwjGsrb&ENb8)SZNl->+~}KoKrIWX0WNO0k*9acTDF45fMG-M#V)-)lf$9s=dP*S z+V9t-ZH}s++1XoTWZ&g4*3*nm?ju+Z=@ifOOPY|dzmZgwtX z&kH&y`Z|UvaU*mOXVsPp!&G+m)gr}+gN@BpY7cq&i!c8!2tj^i{X6?t8u1&c`#7Rv zRk*Q20Dj&kZlTl`zG^5O`1Lb;L!!UcdsQNckXLUX`ZYXw4c<|}Ig#hZY?@A)#Rub; z3Hc-qAN*22r3+npj2M$RtX&ZTP)YS# zymy7i2Zrx8J?4OiDd$Bm(<#}D$i%$(yRmJ9VRpL6@BGUN{1Nj`xjC=hZAaf)la;iR zmxS>dDN%g)|9R8xfFbJEhlRC8>FC-&$zmLv3J?sRP>qd)WWvat)3UNkc=Pw;y@-oO z-=x+^^z>wAvDaL*oN-2@_6yNRs;qCHm1(@V#XLH@PsXVI{+vYy9e5zL^#tlFB-i(? zc?udhdv?sM*tkjxcmu*%)a8dQ$~n^O4=A5mUQWJ|tM$Jey}9*v_4k`a=(-N#)WW1| z%GZyQXvuTV`LMEz+7D97KRW*Y6c&NHRJQ%y`>PYMl+!%!}+ z4V-q`CPNjv4`Oi*WFcacuw)(VqX4x-5UlYCo=Z$G<97PWNP54$+$8NF#;9Z0NTyz} zu`k9H>GD+?e(^2;Ec9PUmWI}VyskW0V02%eqDbx?bEPn`Gle(g#L|{My2Uw@Xx~6? z`}Xw4065@ePuBosw6s{o>GxIWb-duwlgmDWaxIT5(4x(O^&`KIHGDnGr7p?)|52Yd zvgel_-#KN@jF0~ionL7{Ev_@nvv~504+=!9sjHU@EqRY@3iolL>C{E;YzNch=vkM2 zOy9*e?nlvodnLUW)#B3LFDqQt7^kmCmtk%6-TmDPiJ?+6+r{38n+*x>uiuAyTq153 zg%MK*eWhQb3;83^HTU6H_%E}#!9jsAg4%jvF+T(_i|*JMuH!!%jWr<*Q@s7$Nx_CYQx2ywX-(P5yYW!VW(ZZgKg!-}aDR zz|$gpZ-Fk9AUiTLGIm7p`^cp@>$65Y^IFDZ<-0`f=Dz#FG#^aFW>F^RsRe4GP5sEp zs=W6QphiOv>xel%I6DpZQbQ!B=-FO7H}jEkqW=2(v-t7Fqf=Hvr{FX<(or(A~2diLb$bGQB`bx6<@kz z1V7!={o_zVu(-@XwQJ3jSK}ZmIuPmpNGQ81Lv}d$ZowPR65;1G`O=E>F^P#;8gjoP zsGWc3kug6Fnfrc(Hhc*#{K3`!&LxBb(cu1}ws6Pkoyv}$!)xZUZSI zRRy_G;#(%X?9Ivz!@&g5PA6rAR$yeVm?8V{l3di!(Xuh`qkKvMmeOP2r6#-X*q^Po z$izf52PcNk566MmW{AWcBWxl9PGy3HgL-!Z?&6s@q%H(jiIrR{wR-O)X``WU^%G9( zPwwb!;N`#oj>;B}ex2~2N_CpxQ2@Ym1Ms_hoh4v}0}#ZQNzbT%sh9ajH7hIlT6!|h zZ>!<5N-b;V>u+MusRu_M_9aMM-DF8Yq8>aijP(3Vh24b{MSb~)>K(vhFm zK{J2nnPmmVb(L&hgv7m0E_$C@_Omns`q(6pOe421m`I~6PeZC-w1;VM^{{QOd61|~ z4jYx*yOm%dyS6^SHq3E^u2hE5H5Y1rYV{Bb<{{Ktxh7?nrRc{~5d6d>m?`w6^TUD% zD@E5F0|~BfQOrqDE^>ZOk~2TGN_)66>r%WN{HQ&=6VzB&qgFTmO(@F;R}TL zI%fY|>y^XS_=2$KZf&B;T^9@4mU3cs>G^$8f>rwVv$EKWpDv{8kQdk0K9rB)#PTeZ zYAUg9w#}_|{lq;wX_+fK&!qIp#C>2-9gFeA2)4r@R`*ZvXCkgl4=Ei8LU1KJvwyFzIdxKUhDTOdD$ zhg?^{4duC$+|VU(^DpmTU7Q4baCAeZ3Eyhyvt)m_@VIXQy89RZy{|8qx@L1fHp}y6 z^(g*@4=6PA(7;g_h5+Mw#f33ds$?FU6XL#1R8Pye|NONa zd5SVa_NK;Hd0DCqHpc@-@63-ZeI%m$3M|70gtkz&>S<@Y!y2A2WCL4Z92}rQ=?jag z?Wm#KV?dc7uZjIh=C5S+!om#e-u2-gtjVbezzM!kX!{=%Z9?LZQ0h@S@TpWxx*Ce- zjuV?3_%zC1t8bwj|JiQQ>^AGL)XH^KVx7T;anv*rKWX`8-FeQ-_zeg9}vP_}~S z;`A};eNJ9#RV(HDEI5?A&4||urY3ub)i&T8Rt29U3_UO zP{Pclu_4h||EC2FwXN(*P6 zeNt5zlh(!!P~`q~@LVG!u?NU)>v%k)6N8uS>- zM`yAwghV?W?`T_IaxLFs1+66M&^!AEOdPqURpu zX-6J!ITXXQ*wP2)t|D4+{mX#bLjO{vRH@>F8vp|YZTzL~ES_?&^uZ!qu5-NuU#xZZ z>!7$PYdi+%x`E`3mS}L=HMYM_RFbF2HRjpd<$?konRa(vBLk|Ue(uirnDnF;_x`d9 z70#L(ZT-6Y;qNgC(znR?5nuUy47q0X1ln)mI=U@h%#%Nb;iRsuX^=9;+oS!3IkD#5 zAlv(Y^1AlO4TXLv)ld<^-;MY)EQB>HtK7^h8+=CD<)-&xhG_ha3<Qx82`0WD z=2!<5T>2yMK`Q82{@|xk#vy@FF#|_AV&ZTA+9q_HPy30QgQIX*Qg2GB0(hA^T%Sh54zjvo1@JtcN-G69uRCs^zm4mkbI`kFc(pFms4Aj-M!9?? zc>_U-gNr`-2W<1rZx$E@PCFTf^I=E0>uGUAtNmL*E}#5>touAyOjjws<@nCbeBb9CQ}Mdj)vbj}<>ro}H+?-Jt_}CS`JF3X92*5sNltEB&vK)r#?Bg? z^_=B-ez@bY1Oywbd*i#mo(Y|@n`D#hdEI5soO&_Ic;_DN><7xdohUuXLhJXGG1gDW zrAVm`jl(BA+`-6TJV~rUtB>@BC)3>@(q36?WiS&zse{8TpVG=MpiXD?#<1aI>3W1l z;8n{*Tg)+=o%Hugcp~e^n%N32Pa4YUNUhgu*jg$<6xM@O5Ov2c!fW!4(v_XO$)~ z?(V&`6dsVEnqhD%+j2#wpij)Z;|94Pf;ZXGrWidnTk*Jk@{GR3(6 zsIe;898^6At-x3@G!~Cw`GbC&l3s`=r8W6})8+@(zJ3JpWzThh|k%-?^j@;NbXvhc>rFa})bN_x|w)o|g=k*76t=HVuZB^H| zUT|{!UJD*0mNhqt6!=+qJm4Pq|1kC4@l^eP{MX9NjIzlpLdlA_LPoaiT@)&N&ubMb zn=2xy z_gN~JbkvU8wIg_|$9V0^oK5LVXdcBRoW3UkGt6drRsJ~SVE(Sv;2cO(BeT{%-JA!Q zobK)yf470JP2oZ?Z~>?cLTaawMMb30m0R)?*cef!H-)hZpziX_&>W6+a@vQzc)C$? zDw#yaE%4K5wKK?xnAMx`O5bO_O{%ghiTpdw`6C=nt}Z8|d7|$ak|#AG5dyH=Tfap% z^@cQPXrA7o_)Q-FlR{_UwE%94mHgVCM_tYk*?N6=8N59dBdZDd`{pLw4)H(!#clFs z@-O^60}>3|wu){Suf7jY6D3#hx)kbp{paUeiVP2=!kg8kN$yaB z+E3>}{|)=hr!9)-^5TzmG+4hquxVu=M>(B-Ao01rXB&%d5 zj+sA1w$j3f>HgA*$jq0qKvaBiS%E&E=7DFfaYIKZ4x3N&exXf)5As&yt>GBw1ibZ7 zgy)W{!L0p2uKLqdktYPV$&ZOt531e-oOgyJ#}k*wwdQ_cW^-(JIQGN~T(MXhT&YUk zol^nrq-=B2XGF~|N=+MetIud$G&j07ptM$B#xX2jxoZ```O;fFx2gHL>;0)pwU7Ae zIWxMh=g3|$KkYqG{d^dHYy}}(P(3X*Kf29?@JOeh6VIGOzS&%VU1Hp;-nt14)-pSw z+$W)zA(!v&Li&TX&m8pxLH`IRx@!rS~;-* z(yoqo-2j^77JQeUW^1G|cM6>%s@@Eds48%7WyX0P#Hm<3?{!cM=(K+aW+_@cV#KsL z^fw4X80Ha^hgos^_bFwrJ+ALnd$qA4tnf*&dULaa)J&V?rL(3lnc*@-h=F)2r*y(? zAemfPh2-m~wX4VAJo9&Knqzk1lYpb$>86M6%!B6zx8BaNE&iM20Yd8o3)=UYNw zXnpAxeLSjj`*MSn<7K-~1>;$1R+U7Ka%Tjdb-wQJ8fJh8^dP-AXv6h_gd{SGsiC*R z2n#o@!jRF>z%0QT0pYk3UhO$3`c`Dr&toU zux#YPKzwD+aRh0l$0dQ5g5}Ore=x;t4%_&gz~n zE05F7*Zh17&-c+gZd3F+24(Og2rp4PkTxiI_t^6$C4}sbRLJbT-ibx+d4=yB-zX=ZI((Euo)#&?fvNeA|K3 z{%W&JXYa=ASYj$pOk~9S$K=tjG}sKX@oa~74n{3P2t|l)`h#1&cW2g;=gCjr<*y9c zFehY+;%@IuAB1PmKSOX5tPYoy>3V#WHAm3k{Q}-t7=;qgY3ovve`gTb85o`x)-WXS ziaGA=t8{AB0qf_l(Le!OUE-b+a$W8BjV)pTbVrMp01n-Qg%+o$B);cvQ+8))m-+$t zBLwsUX&n3;Dm6id_%?`#7jD<&blX~<6A(>U?oFJVja=$xDOj9@F`HEMD<$^H9~^XO!hnrs;0k zhasw05bxXq6R2|x2{8h3qWP;*cu)ew@heS%CY==;^;6qS|f%F90A=&jXl;WfC8w78+lBAA><;N>7mY z%}v}elSt*?$$1K(ib>0{lf@Fr#Z|2+>334SsVgJOd<~_g`kK5kk z{4J$<^11J$(G>>b2vN*2tV^&$t-rH@S96~*x;drjK2WHmo#j}Bb2ncy;&2IDXBJ+B z2YK+?oNnk8C&`vzt;+q6%{0_G4VF6X$LmymvtrxsG#>^9e8ea!x}AANlNQWrJKa}3 zAPRp);`)WXPh10_9iCBpTnC;-tCty{GJ4#~GSp=jJ(zm=CUPWX(h+k@VpTDZ^H-ba zuiZBnl2VTjGBpj=6%(0d|EK}5@j6-9d5=JuBJ zLv#y~mrhO_`1O$QKmLMN8HA#=x zIp&;L68=GX(E(UIu35VkRdDKF#)@PLzuRj9E?Y}?{A~6`@oqQq)6UK6p36;J*q0p1 zB9{nHwbJDidwTLo%nXixk#nJcL&;=K~h12iMGNS z44*nXLB4ymXn@-6n}bV+lZHoc+Z__q{H_SwT-JL&J=4`a|9R}=-5 z7b?~qZVM?3@fp7*;km~wbH_+P}zlv?$ zm6g}iB1|ZMd&Kfee6C4UBtYwAS2oMJ^X0YH+9|>^+3^2OD?D^IDMHR!LyUD=~f|^#z@CFT>XfbOXB* zMaoaJC6!mhk=o|x6emf)<;iwS6KdNJDTCUU>s21j*p$rU9nP*^<7WQWcwRPDYsA!2 zZ*szM??zMWj8L(>nLujH7k*P_|H_?cs-n9Eqd?}+@pJiRf*e25>2%XKlXS9>?~|sZ zo$e2zHt0y;4LxQ2$5&2^y=nim-mGeS9A#^d(<#T^GU#hLk2pTCx3=hSKUvUGYnI+D zGzd^rA=E4#!H?Hqi11zd)9vXVPAkXVitgSn52H}uCGoE;`Y_)!=xm)aFnm!OV!1ae z?IM~iN&_1!A#8U7QWl9NKRF)J{jSsDbPE?L3>6nTWxa0D`E_@OQ(liN*%jvT_4Y66 zoAb|!ee>Bk1x7A7y#U|W@{{3&M@4bPb!3F8<7U`vG0NGl#X6NFi9;XmdWCKltWRH5 z%I*&`S6cBaRGc$UlfSalDn~$74fn+fgYPPc!8k?kGGu-7>*srH=*rYc?WU%m?rT_S+5n>#lc zVG)s6&6`CP;{*upH5DCPg1Aq0B9QM4FR?OCg^}sQh|$>we~tRzah4=!2J66j4=VB4((Av`{&uH4ag%PF`e8P~d z(zi*BY1YOQAF_k|dqB$uR!I=lVJRAP%{jGJ?WLUbtUt~MX^ocrDbF&D> zjMcvH%l?A&YKK>7TF=QCI8FDG>QNs3 zmea<_c%asSL!&%y1t8pDyaZD+8K;)JEw$QZqEfee&w$ot%*LT=`+Q0$ltD~O+~Fw# zciCI#=?Lv_L9(xR{>U-|krsDc&>fSjWZXmHUABeaGywu|nC=wKjido)T)_y=gQ_fI zSG+mVDX9iiF4{L}VPC2R8;`;D)nmecrLUa%O}tH4V88V8+NS~UNfEVz1zR@r0`%27 z(|aA%nZndx4Qab5@7trlz_5ecPDT&3XdzrS4_uFHFPAmk%$_fQ^0H|w6j9;p-7znL zYrLl-W@~xgCMsGtMs?~NHgVc~*i$qiJ+&qBioZ%EGdu4lE58YsLm+yu1%mH{2FHh0>S{3^45 zNW0~D%F+YVNZydKga9?^Z0@Wv>Qn8XN8{vy*bQUaGDtY>=$%XywR&bx#&?ODF?=+D z_MDL*+35ukM?>^*zNGG?X&uihFw)}bv*rP|_hR><)|Q%#kU)$M^jsBh&0AFVP4iQU zh;yjb?Uwgh&|gTcnPt3dh-EEtc>vOCRZHpt#C9x>I@-^HaN+*DFO{8{;iVjq*O@}1 z_3G6y)yKzWn-$NOC5XG@Noa0|9o$h_cp^DLiDNg)5GZ=Lw#+SY2`s$D2ytvYv@8=v zp_vR^|2nq)PhB5}k-{bvmQicKPB+4IEk2=)22Nm;UG(a}`~C8YZpy4nvk~dvmtJ2x zmzOS)$#iDM`JoRkow)4#ziEs0#>_^>)G%-QNP7L{EIDKBI$()_>bP35HO{2NV-Phj zw5iFU;}y*`Z7614X118EAWq@M^93NlUYBOm37=yh&HXdP($RQ300JO<-tYwXv3se? zLG%&wkYagkx?LVJ{-x+=K881Z`A&o6ny}|7ZJ!u%Hx$C*7XUkQ}q`*!!JrFBU8fO<1;WIr~z=FQRX&wuZ&*ANis~aMQDPrR2cG#B^35`;M?+LbXrPG ziaua@KD{hX+y#W^g>2+F_LBuSn;B_cc@k3Sd;i=yMJF_uPyLLirH5tx4Yjn#lQ0#G z5i~BrK!mBMO|^?0G++h`aWJp&EKb{V=QhXCgCpF$wY(iV{ierrOKuQ3MSxUH;=XN9 z-{;Ayv2LwTDjg>qh`aB}@$Omb1S{Brk831Vud6SO{=~b=ESE7p&15)U9iXsD0jA1D zPcj&Prp=j5I1@GC>{S**x=6seJ}9GRmwTAS;URMxIb)4N`WRwcSXFVh1)B5-swsH(XV{Hi8Q z9%kR6UIuK12P~}00g23D%m-h&doy?qCSk?@wef5(|FxYX9N9`&w%Sc z2DCe(0jTkJOxj%hIeedSbX`YHfGAouMboq38=1WM=Q9g1A~$N;{y5Aj2AeN=SAC8q zkU_y`sx(9}LsX22wi@SBiX-48HiyHIQW*N+7EvU>;vR&xU*bSY+A&uh!|KkjT?{JBtp)kf?_G_}N z9!%`HtSJH%UnPF?_cCF);H58qp+fR7*FQgG4Q^j3`ni~U&dZ6x_G*f@;`l>=ZoDAf zZRhHMM4W+6(b3Ka#|C4q8yCz$^=p9JN0tHb;$U@odcn& z*0aI)ML2VKiIW>EJ)z-P$|%(5J)W=N#&BcQpCwa$HfVguF#$mVSeS_rg+}Qym}EFW zQ`~B)|DN2@ra>G=AVJqINQNf|b_vF>=0s;bZVeSDy(xXI&ANA5}ty5Ch`AU^cwMV7a{r+i;Mdj&12#ZghIc6AAyqH#aEi-o1`H7??G@lB-4@l`amoq_bq0Q@X+lQW5zW5~iW|3bs9cB0O{;ffYe(Yy!RKtzfPvlehd&B_ z;x`oz29hJQu--k(12Bv4ue8T%2nrRi6eN)0y`%9U;Afi$SI3Tlj&`E4QRyoq$_-+9 z*ilA5utn5Bl%4(XZOg^;qN4(UC;-fpg2aI?beQvM6INsCmbU=GN3QsJAkaZ)4W*~> zozH#rr{6F9)UX|HFbq*tTYsu-Q?Lt+pEO~&Gla?4Un!DBcd6^-edZ`fro9{HEvC4k;|wL=o4Ja?=%COo>z|*!`Y^;qa>6Oi1vuGy z);u3!YpC^=FURo0vSF4x`i*(sNo~v%qOvIv>6a^NescwplS3Fy&iwR2?nFfOrw>7MOJ>B}pi z(rHpIDk@%?hX*JK3JI;^5&qLIW?4l@8YuSTBSH(gfZC$doJtzt`f$+o*h~n$*uFk5 zBr)h)dVGTukybSkp!P`aS!rvp82;<^g72M#wq-`EwjZHV$JuQWnOO@fx4Ibuz7PwB z1hU!^kaP1IwIVq5-f|avBA@*$QaOKaIkSfR6a>^a-0q*M>e5)_w0!2C5FgpDj#$f= zgrV>z3(4w9cm{UKe7zpXv)3jBihHvLp@}5?&Lp_}VJNVN{{Up@P=Mk6{u;V?z5Rlc z@*kd+g^@J&_?>1c9U>KY6~rEEyyEKrd0RW|%MXCy0=JR`B%orJ;xYRaLqfQdgvhkn z%o_V0O+$>=`(q_U8{nN9*jSqy7CMKwae`T14DclX(nwszhTw) zc#E7f+~2zz1|)M-XR{R8#lzu8yV*`90NU(;xayRcc9Ds?h*z-l6x0cKiBO3hMKB?d z5nKwA4^#{=lhwk^8|mZI`xKYwSPKUJB`nh`s@+QV8+>NNHSMmX@SeDO<}=e}jDE|~ z&1<(>VIPa`IM0xLi|xnqYa8;4QQKKp$&=NL?iu;aR8lAi03ei*3(HjxJ8NENGvCNc zf{&N6Gb)8t?}rmO(VWU_4#^DKQBF#r27 z&?9&Wr%o&=(HI5`K~m5^kDy(W^bUV`q%|34a}Doa0vpRWUx(_?ZC-K z4%1uOVkFPJ1^e4(?Zg=I+_0~gY%u_Q3*eiV9Cm!ZVtIqG>X&lUr2EHKhp9*eUTq8k zR4!1+1L6Q5MzD*(yLK}Ew|A1-<+I=Ict12buU|YsV!|$69{VGl)Bp*q!;fKy-^gby2BJJ#)1Q(LB!7+H_~l64bXa790zf^wHpVRZLI+APoG{%Gp^L1VNE4n8*VWQM(!j&XU2iDZ2&l{O2A-4B|`6s+Xw3V&{|*&04j^?Ak7P% zze&3AJLkPlrpqr8dsh&r1oWYS52N+OhntSz1pA2u#lPhtQ!)xo-HvM%^_0lCox=s) zLGmg|Oh{b(q)+hxb22N-CnZqVFfb35QU$bt3Y5I6#@oEMeN5eWDP7&g3<#H>%w9Ph zpr-N1dezCsJqDU;D!vAHTncVWL5FbWPvyb!qeXvnrrGIY;N!D4nP*ZIJhg|DV8rdP}&== z2=0Hq=cZZF4(@!s?-KwLi#~HN+@DY@$r^~$@P*(b%SvrJQy=0n2NrLZ-XY(EWfvAP zGJ?2n9;G=+LOeeyb8g!+W-QVvk|i`NNakU80V9=XHHMK2o%Ei{`pwbXs=sY=h+v`H z+CPx~G@$ORClC7txH9?}(`=xJ|M$`hTkL)1q&C2r6SE(Gl`{p+L7glmKIJJNe$^`P zd~}V6yj+uypeK$w2W*SJo_pl>nLiD@6u9@|`bRkk8FwxvQ5udIcY_aQ4_=y6G#L8! z_rGx$?!a}F-9Pxoc=KTBc`%zS`AQmQ^o(}~y``4bpFhUn3c@NQ@n`SEI0v$SouT7rcdvIFC*SVs|^Lpo(Oh*X>Bp2Kw4{$1thY`Qz3CU^7v4Ru(=?(Q?oe zppw^i{QybBeln0B zUg#Mx!vN9GIjn2>>KxBNtR0C#uUm&5ME2-*5f-QzI9rSl9IK20s^|pJxpBcbmFg4L zPtIC{jaOlvru-pCqFG^*a>!%+(~uNCa9swY5A2KYw*Qg96TQ^X+{f^!xgKsaPq|)7 z6(a5XRr&!ANoUihSEc3G{{mHA@TchN04*vkrG^v-A_ltIn5m&+Z=Aq9E%d9x!@=wGI zpvX{bR>+$g0;-&NU#SmBEx@brfL;VAKI5+f?vB9II9{9B4vU}%mxKmZtF7Tyo;Mtf z4z9k{5J`3CVMYLr?cA?Gz`rE#~V!N>vG-@u0!W**4Xf z;N$W~Pt;wnGRph7o$BBZLRw_l1Y!_n>ndb9uAK;2_8*(zb6G_&(ELaebkv}PfbRHC zG(elZ!pWz>#+r@lVCz5L*$u`zxoJUozYe#_0b{NK9Mmyz;yYHLa(JFXn)wZTdl4f< zV1BZr^@WNQKFmG;s{__~CG^`S3g0%jfU3lWM|om$iH#34oO82ihLfP>0EO&J6cTN) zxOBpql?%qCk&ivA`%Z&1x+saQ>Nmgy3jI$AfUhSHP^yHjIABTH6gc)jdB3&czbzs^ z3Qs1~Kr=8{`6mIJ?S4P+^JNfUz7vwm2JRTK686Y2{ZZHi!dqZ(aab=fQjw=+W-y4cI;cp(b>p_IH7692r&3!R?w%TdJaa zhjo|m88XrCJ!QSnxhf-UmK0`80K1`fX=RlvzO!3(r4V)=qffp-__}>}LReRlKuSU4 zssL+a&shgxN3_jW1cTNC&?It24A%K*Y5$Rb5=sdVEA}z@kNTkPa=a$G7Ue1LLi>^x@dgq3eGu90S6xOzER`U z!QCfcuun*CV{lqXkGe(c|4Rdv9XUN^D6`dv-Nyb539Ngkfq8&83vf(yAe`O)L5=7x z3WoH|JChs|f5zt@=Wr{CQ7@stoYiXEgdCGD2&D;i$|wF%cyf@*|E~OzXCJbR1}{2O zgZuNx%TUB7Hb~#Voma#XySV=SRmore?Ki`?*x^ zp$(GwMXkEPA8(&quu-a!xv}zcxK-rfF=CF6WCi#i3ll%x{;6)v4C81b6?050+sO=BP0ptl@{&N(<~sM^ojI6zq!BgNI1K ze>pJNhPdOT<_-r=biS}|N4~|K-fi+;hzq%z@`>u*7~El`MdUN#Wdgu0R#s}aW0QJF zrtW;3$rWEu1w}6Oiicmd*Y$o-S*>edgvgoB_yucuP8WoRIx#o=@O6?r!$JyLtqbEE_8C_Pnwgnk7L!6a}Wvuf*zw|;_L_O>a&7AAPH0}G zd{t}31nL>s^H0EX`QwbmRc8V|B}3a9|J{<1Bp3V>(nTa|OQ6(YudMRu%>uLz)CkI^ z1^-k56&Q%CuyC1HJ!;VFF9X2-GOSx#B5ocD0%Q%YMjnQpnDe)sBt*zbg~N!w1-5j0#)dH! z2@qc=Cr=at(1zSsHP6sMSPn74_eV3rYmrG8a->-!uSJA4ym$pJ4?WD6%y9C%l{Kv( z(Ev0IUKEmDlsW3exjx<)p9NR91Piahn#z;chJp)mSJzG@E$Bmm<)C97bJo3a_*Xz+ z?(B_zM@{c!Sx>@Z=XQ&Wo7dFUUoau*qb)}IPM*{MQ>@^ zRkl5Nod?vq-)+y}PYDiiovLwB)9u4SCEqm#Sp;juJt%{%c(GTF)NOu|($55_S;l?H z4i6K<{#5wqM9}a=JI6w@^raN)uKl=D{`Z{z%ssCJDj@VwDh4z|D#QHqML@_fSMVeD z3;GJlgy!e$9CCaW6r^y#mv6j07wzM}2;_VkxFpz6k{un1FOX*x#J5kY{BDEmzMGcA zoJYfZigU2{YI^@*ngA#r;KZSQYo4Y#Gz9eqO`*+D(a-r042PH|%7CAr507C|&m zYxsI73=#rjvf2QzpVwa+KT1uc@~nLQQ&(7-SmMXv0xOak~QL%|0`4P zj;dqbmLvgOG)C8SzwSbGCNW|`iKYc278cE1Xav@3Tsjk{we7Jj^u8|Xef@7w;Px}U zvU^ks)f-jzZV4j7Nm0KW?bC$F49v zoTI;a6x>}Ae5d{5CFhnNiQI7lTQ6REgom-D(F@l3OV+T9-Iw0U?Sj&z_W2mzcD{W_ z-;cMz9JJkf!UQ_I{k4jg>{9Lz-!CPxBoV?6u0Wu|ib2tWw8IxDt~LqN$-{Qs3}n0i z`z^Wchi_!4RZXDbO)?W*jCFyecO={&f6ZGlCsSJ^3pLs8wQ_csC9w zE(QsST?zyXwYVYTN@oKfFI2;rNt+?ud*d+f#Gd3`T(m$PAjs&N?tz_?WM~ znu4ZkxlAZQ(1W*OdC7hBiWc^DE^UHp(j)0)?l9iFn}-t@Te!rP`qj3(pi=}}sn_zE z&_-mLAJjh+Mry;B<+DZ-ONvV|G~6163aBQA{mXFjw zF2-38%41suKJY?5Q9P}LBz^S15Qj@-Q(K;4p(HbvQc2lV=*P@Y=ifiqUQpLz7_Ron z;UIE&eK}pS|8UScnRB+DYmos$#&wZ3tbIlZc(JQJp9)TlDcz`V+&~0Z0g4KF$8}Lm zWdyCy1A3cqI8Q$fRi&pkVFN6CU4suPaCUJ?8G?=X#Q{L&1xCy(L-uM8i0G9KF*juT zEM41Y>3~%)I(SWYO1Ja(SDCSG5+7X_~cjHGsOBhej%P9tn)=u@}xiUMZSDVRv>ihGrCR# zcC%el+t(AUhJ4Vltu#JCT2`QRcUAlIt+@O99|MJ;*1@%i?-3f1-|O@D+D#OA3F7-I z2=OU;>GEHQv?zgT$KF8xM{cd?kW2f;?U@f?ffo;x4EgEpF|a6deA86b0YfOiCN^qY z{yf~~^TAiNO^pxYx!fe z-h{|?H3ks@g)qtDo=$$Vi1<@N7pSxdFCBhW7q3m!h>*D~*rH94iGS^uU@y{mI=}{Z z&XJ+Z=lp(Y2UMXPHi1D3GPck9%<=iK_J@Eu+he8>-|%(fGGA{qsBj0A7pofzxHYy2 zxR|#!90vyP^%UFQuNZhhC99bPD2a+#MKnuAoiU=OXt@Nbe2$(zJd) zJa^@a`1;%-v>^^I;KASjtL4T@777CswVReNbrhANqgxd*g3!{f(k0K_YhWse$}gCt z`FuY@!TvRj8>Ot4l@=OCLh7?>^HrGsP7KCc4i5wskN2rP$%F+fu_`p`2Q=K%OOJ}u zHz80^PPQ*+`d!%Ci0ulodvuI~RvG`iI`7GQ3RGjQ&2x>;H>t5tFyX8#3kf`xU!SCT z2^+WIZfabGsn0pQNM065?{?}tvSwuCTg8fjqcVd|@vsXX4lls$p1G5)_juD@zdpb| z*CaK2&dz=PYx9$$y4?5LV0+cLylGEun2DP+?haga+P*pv=*0z8br*(Df3O`_Xy4eT zA-<%sDWLPIq!LZJyLei+ zGwYm3GS(C3;>I-D@ zv{6$x$)%h>-|V-AYaE1EJ-vE{j=A^exa|0)W5O?*AY&42Rfz)Zk>!_0D_<0!;Yr*A zXIii(M)fz`C=Nz(hSn$r$<+*cghHByLNzc|)@k#T0W?Ac4d65j%H1d-bb1+`x7+uh z$6Ryt49ZrXadwpEKN6s6lR~dsd(@m_y%T)%wX0+Ejx#mjr<2-3eVI2eBLg4)ShPZ6 zDxiH4Zp&k@D`A=_sM|sAj4#GMQj(ya+i))~(Z%v5M&|1m`aAa)M6aI81s9Sfzxz{^ z$oN9-1|1zE|M1-)s69nU)Q#a7t=NRk=$lWCVa?X15miF_oagdxL=b9<_Ydoei6BzD zb5*Jt=Y0HU0?%G)#Uy`7E~0=OVK`l!Kg_jSrAb)AC>1FLg#}9$XhfyyaiE&=i&} zau%T<~e9kR&bb3z~nZVmX==S3@>EG)IAdE z+U&X$71o1lkFq4-FEg6xO_;W1@5QgeheSp)Dy)ocb^qAfHS;o|6GrpUl@R4$1*H?* zeZ99r?2LyO%-UpKDzC$fl?CjO*05r|XV`>aE{;y@X1mBIHDeN$ZoBMTx7&qp9E#FJ z7{KbNHx7H_+e{Kyu?@<|Ah&xAKP%jO{uF`58lB$a0<8l0aihzdDLSU45N}e#Gpe#9 z^GL{v)e1u#BZ9Rmzla=1Kbo|Qy-j>_4al9mNZtuR@!hj$C(QcWR-3<;xV{+DUP_MZ z+b|*#8jFCjPg+(xc&aoRtj$-?-H_U76_r;nOfFD*0A30brkhoHlqk%+4QBa-1*7=`5auajd_IeH&rG2Op z3h_JbG|#Zs*0mNH)91}Br*(^=Uhd!X9*&o^SbICeM#n;MPrK)y79vs|DG_H&7|$s^ZP zl!oCOV^VaQZF}afDV6wF&-ts`?b!=2TYXGOz$Zff``l4W$|$NeHQ$|RC&AzIm5x=K zY2xao2&H~pbkeWN1PSOGTX}G6J^7>sm0RbTB>Mx7N`PaTzP@V~h6{~SwQ@Wd z82^SI?^UE839^mu<+N-F!D9!z+|VOj?BMZ=GLL-VcK7>~8zhpd;3vpn z>z=e|TOkPe`Jxw5==Kph*yb~16PlwfSz4Ty_8EF(*~O&By`Y2>{6iy_Fqp~y*s9<{ zrbJNVr_(mu3GFmF+x&X+XS0lOkbk?vPX)dN|6wtFOf6wrF*a)3L7A+p6*FEVJP|8~#2JFF<*MUTq2Z z(=ty7-ZqoIvuyN)OBDr|&1s(Kh-jL^0Sl!Wr*1T$*))IF9m>@W;>C2|r7kbzi9s z14SRt5J-;KYnW)w33e1F-O@yJ{r~<6rNUuTY5HnaVYz3>$FZ5OjMA`w1)-WJyQXQalMX)qq{AQkPF+uTgQ61EO4b`NwyjDc z^a^*L%}(}`AU)=fqOvk7(kob5IkyNh%8WJK5>l6iC~{dpwiGUtk|=rtOK+=tXf|aw zM%#8w(`6+`Ms8azo8-1h;v1CDG~5e5H_8tMG7vC)1iGV5q(KbK}9odgKNxM zsa`_j94m2TsYyaek~Pn2RT;zc5X*^=wt3S1e>ksy4ea6$0aw1FuIn6QE$W>u&K#;D zQ(g<7rg5sECVa7CUOZ924MTvEsz?x$2D@*6@}7B#>Bu88FmG#=fPh)7y53VE6Sge- zruoeKIl=u(+`L1S#<|tE^tsVCkm5e*C()&F`ZnzMulz}=MJ%>YU??N z)nd_uc?+utL7{D_A1OoT!`ZKqMZLoZ*#k;^kag(0x%^oWSJT8q+w%LPVn*z_5w4}g zZy|V_%O(TlJaljs1$KmD;X|O+c4}<&vL*`6_Lx7osJsf-5Nw^h;F{fK{^}fJTXawT zQ*&fQRMY{fIr!KOdWI6Bkis?P3d%nNe}t{)Dz;50eHKDqnKU^U(6?XSSnOGR{T)Ih zH)HZEU#MLv69;SEuvmz5$BE7-+&FEAPQc#{KG-IdWcmlH*Fx$`(cnxF5O5~)gscR* z*q4!M;&~G8@}**HuJDD9j_zrQ=c8kfRDX@?WUp^$4FL>6W&@3ngRokWRH_4*cVX&j zQq#!|WaUc%$KQ~3ZMv3Kp*RFifibTQatMgrjK~P_ON&+IQC0!-X zgW+zTw4>-#<@gaMN6rceStRYprSHYVoX=+a_O`YTRU2@JqGxQ|_`jsC%g(RUn9N3T zK~q+t6QkF+r7%_Kh3v>NK~*;51qGYpW0cz9@%fLNA1CzK*__TH#F1fTD6A^z6MXJH z)=>kU2zgT59opX_#3p^^jlbag#oYGwAZ=IJX@Yiely3Jg{mPe@hxnHs#OBZB*q5pT z&sIn4OPbAf5HuV6u$;mXii5a4WK?*nkrWf9huki&l+;%+=H`}25(D^ zt>a#nq3u$K6S!f8@;H}U?_RPV_*A%Q>K!vKgiz zOX}yvnD2Lbr!pu(@S6s*}8D*6t2dlgXL=%0SIh9;`cYCVAwsJUTWMB9Akk zKal}Lla0f+AZ^8fOdM#%{XhkMVC>~()B|hXnQKzDa(SW8NT9Pjbu;dMqv3O{OlP@P zVX?vG zQSXc5mv-Aa9I3CzpFqBwhzkmjb9XE?wHAco6cmO(wa^Tvp8vRf8o+#X>6P)80q>xJ z;cv(UAwAtH8waSZN6HsSvG@i_vZ1z=anR6gQk!cI+3 zr{|2Xd#|%dqO)+{-DXY{6o4B-Wi@6$c(Wp`XpxPWE603IbS!a_1f;Cx#ge|AU{q|x zebl=++`QNsT5k*9V~4v+-OT$eTc13#Q|x8W`|O(Fs?xYSW1LEmI|%si5gw%^?5w^T zcznv4%0U1UBw_>+t|o-xrcBi zH^IQ6spOUYjA!esd-rG)R?s~6Z?XEGX|pml`@LJT!L^TNWMx;Q*>wv0u?57g8b!lx zqt@Abtlu_UMcp4>cPwg)^R~HBpwm41+>?Ti?im!{TqZclWys%q_Bn;z=tR``sQcV7 z{+lfy_s7WCXjJ-qv?r2E?gM(^edGNJ3%L^OoXS8~E}K6Ol%C^i3g4g&^Nk%6YNbB0 z7*68kG+J}{&pO}KY#SCr*9fxa=YP55p1M>BA}0(lgZ`$EPw%c^P;_t&V|#3I(wIB{ zWWHcHeky~XQ_$tDM!3lcyn$tBHpz`<`+xQ#_9H_u4DT==rj}XJqrdT5jC036lY?m%2 z6rkbS_Rg;xKUsbtqeLT+z6?@mB`3bZxFMYHuQ9M;muJUAe1~iggk;01ozpTXE!|<8 z6~ke-iV%Xv8Wo*T5H&2dBM8|p{QeA6tP+!WGihsOe|P%txydcTmX?-E&wcvMot;En z@VZas#&^+2Tv_)2=2jOF1P@>hZJHVjW#9^Lf7KJ;&zYUijW01p3+)iSkNBnUtDZ;lc+)P#n`zykuylb@EP`pyG!XI)2>x z9n|;@X?;2h#r==y>=es3y5#J(!8P%|5jIS>G+Yh^xeySP6F1LKAy?CFpVwnPV$l<9 z3$s@4!+}<kGKV+47~lo>xIdlg?mONMw^18@j^wsir)vCDuso zli8k3OCcekiF=AAzQeZB(*u>~HTlQDS;@7`flj~Tf-jK$ves=xf%+yb4mYh>0dRe6 zX_oC7dPx$9<>=pnATq5+QHECXUcO%fsN@L@;WabFF)#SnwGT$X#Jzefu|$*K*Cf|s zBhEFd@~+<6bPI)W)i#L&HK4V=RebUgU-Y+?`Mo#VeW^8F3C~uEO+_3QOT=tXWmEx zmN2^95_8a|Yt>_SkKZll^bD|*EYA726PUI;xWaNo)yYyouKq`*0J#<>eee%^5aU*R z?h7e=@zbTu_EREJ3@@v8E{AB7hf;UYWBhkuDc5M911Dl61B%0--oBk4dp_jaLiv8} z^zfKj>>q~Zw#0XFIsa@zH16ro@svt5(H_Wy+t$(>7hIyNh-Ki%t@&o%JrW(USCq|L zmd9h1M0Z`$oI<<+ld)Dx;Oe@hv*0t671HM;@CV)B0R1UO>#Ly3%=r0Bsng&#W#|%P z{;SjKQd8vJr~rpy@c+y%-V!{nZb1BEJPNN%dB)Pbsb^cH1sRGwwJ-C!_7SDrT!SFj z5`ka?rtwCu-;ak}U!G%T(S~Q~a!l$S+%M&q4DzTx9R?EJfkhUAg&8Ia7N$u6$;AEH zKe86T?&dR79CQfdsorOEcG{+FN7fRm74PENbr6Cv%FZmENyFs+>C*+Vu;kCIRns!NO27 z76v}}@Sm-?8Jpv4$3J{aYmTDyoI`;>&dR^=nMKJg4&hh&iG*|x7!at&Tt$U;A?KOs z_EQS-Bw`9XufM+Os9;3A-h%uc(IDav|Hjr`Nxwe7wv=4c2WhBm)}P*cKeJ3JLfAE>2IM&%oWbR+S7we`1b9BOc$YzFOyC#!V^sq^`(F&Bq4 z^RHIY6U3<9x)t%rVZ2yp9RO=uEjn`%@*m(&LHG;I!JABBH4e*COuCT z-R2(eY2SDBAIF{{^!PE*SQzl9G~eo$$u?+oaNB+Pot$}nfzfYrut7SH=Bpx|B3YxX zqloNkwx{$B$zCiTh|CfO)M}|9SZUUh+ye4f+$-6OnzHzl@8i$1IDAI^r%F~6BS`<| zcEyGZl(s|(o3c2bwcqm~KU%8=`s#1bri6P^a)NnI-*CX?yrJ$Fq()gKuxH1zAJq`6 zaW>nBYWoV01ynq}vxowR7;{|>3$&Y}3NtVRHp-n})7^tT$j`Y9j(lPGQIIKOSz=j>JZNR6n5X% zP#v}U_SCyW>z%65sH6_kA=@X}O)wJI{dRc#^=aQH4us9jzL(k2m~OamTvI8il1`I< zZa4dgvcGz+4S_aIKp??aUnO@c6RzY0i6HE`R&w!*VLwPN&}CEt_i1GjFb_RElWE_gvTp;so(BFxW3_us~ z&fgbG6oB!7xQ_sg8+W09P=XI<0^v>cE^)U!)LNtz6q27lKp8YQkDVaQUQjdiNWAID zjCGfy5HRwwzRf7{k;o~{QBX6(*S<8?5{E30xgF;-daOaB6s@8`B=c&9ea{NBEc|OR7MokHyJop`IjK42*YwrhAriA z(ROo#0(Dm&>$MH#s2cc?QUXm@^8m_Vi#m`tl5jUZzv+*-zs#UIYADNvMuNoI32tQm zhi9LMZ9;KTKO)vS-}Q|O+L2;fS4kd$601(%$Vre_ac(Ol$f++&jgibk9DY$#3nx38 zu7c*RTDT3l)Dcx;Rc=oZit7&+Sx!)W7C07@i~nw~&o+Dz*?UH9Mis9ZvrUNkH>d#@ z^nkx2dvu!GXw+?8CXwt6CBJ=*8M^_IuTKM$UA5Vl{-U_+qJ}V4QCfPA4ZV)l=FUAN QIU&GfW8r|QI_E?BKeE4lMgRZ+ diff --git a/content/ruMarket.png b/content/ruMarket.png deleted file mode 100644 index 05b7060b097ec77606844c5d3a3b98961793caa3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 10915 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 s@=aN+^8f$< diff --git a/content/ruStore.png b/content/ruStore.png deleted file mode 100644 index c72ea833cc296750b095396dae7fda3fbcd525f3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8331 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 diff --git a/content/unittoModules.png b/content/unittoModules.png deleted file mode 100644 index ddbfe1a62ab43ad07ec600cbaad6db2b1284e996..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 51263 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 EFZB1bEC2ui From 225c35c35e3ff93a6ee08cfac3ae2ec8170df91a Mon Sep 17 00:00:00 2001 From: Sad Ellie Date: Sun, 28 May 2023 16:30:53 +0300 Subject: [PATCH 38/51] Lilac Luster --- app/build.gradle.kts | 4 ++-- fastlane/metadata/android/en-US/changelogs/21.txt | 7 +++++++ fastlane/metadata/android/ru/changelogs/21.txt | 6 ++++++ gradle/libs.versions.toml | 4 ++-- 4 files changed, 17 insertions(+), 4 deletions(-) create mode 100644 fastlane/metadata/android/en-US/changelogs/21.txt create mode 100644 fastlane/metadata/android/ru/changelogs/21.txt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 24891849..1b63b91b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -34,8 +34,8 @@ android { applicationId = "com.sadellie.unitto" minSdk = 21 targetSdk = 33 - versionCode = 20 - versionName = "Kobicha" + versionCode = 21 + versionName = "Lilac Luster" } buildTypes { diff --git a/fastlane/metadata/android/en-US/changelogs/21.txt b/fastlane/metadata/android/en-US/changelogs/21.txt new file mode 100644 index 00000000..a1fb0e4e --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/21.txt @@ -0,0 +1,7 @@ +"Lilac Luster" update: + +- Non-mathematical percentage support +- New interactive Formatting settings +- Date difference tool +- Improved performance and UI/UX +- Added Italian localization diff --git a/fastlane/metadata/android/ru/changelogs/21.txt b/fastlane/metadata/android/ru/changelogs/21.txt new file mode 100644 index 00000000..cf6f896e --- /dev/null +++ b/fastlane/metadata/android/ru/changelogs/21.txt @@ -0,0 +1,6 @@ +Обновление "Lilac Luster": + +- Изменена работа процентов +- Интерактивные настройки форматирования +- Инструмент "Разница между датами" +- Улучшена производительность и внешний вид diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 63e2eb86..b04ac260 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,6 @@ [versions] -appCode = "20" -appName = "Kobicha" +appCode = "21" +appName = "Lilac Luster" kotlin = "1.8.21" androidxCore = "1.10.0" androidGradlePlugin = "8.0.1" From 9f6ba87f575678857e18aac9dcffa9aa323cfb92 Mon Sep 17 00:00:00 2001 From: Sad Ellie Date: Wed, 31 May 2023 21:44:11 +0300 Subject: [PATCH 39/51] Fixed cursor at 0 --- .../common/textfield/ExpressionTransformer.kt | 26 ++++--- .../core/ui/ExpressionTransformerTest.kt | 68 +++++++++++++++++++ 2 files changed, 84 insertions(+), 10 deletions(-) create mode 100644 core/ui/src/test/java/com/sadellie/unitto/core/ui/ExpressionTransformerTest.kt diff --git a/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/textfield/ExpressionTransformer.kt b/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/textfield/ExpressionTransformer.kt index 16481555..668772ce 100644 --- a/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/textfield/ExpressionTransformer.kt +++ b/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/textfield/ExpressionTransformer.kt @@ -34,21 +34,24 @@ class ExpressionTransformer(private val formatterSymbols: FormatterSymbols) : Vi } inner class ExpressionMapping( - private val unformatted: String, - private val formatted: String + private val original: String, + private val transformed: String ) : OffsetMapping { // Called when entering text (on each text change) // Basically moves cursor to the right position // // original input is "1000" and cursor is placed at the end "1000|" - // the formatted is "1,000" where cursor should be? - "1,000|" + // the transformed is "1,000" where cursor should be? - "1,000|" override fun originalToTransformed(offset: Int): Int { - val unformattedSubstr = unformatted.take(offset) + if (offset <= 0) return 0 + if (offset >= original.length) return transformed.length + + val unformattedSubstr = original.take(offset) var buffer = "" var groupings = 0 run { - formatted.forEach { + transformed.forEach { when (it) { formatterSymbols.grouping.first() -> groupings++ formatterSymbols.fractional.first() -> buffer += "." @@ -58,18 +61,21 @@ class ExpressionTransformer(private val formatterSymbols: FormatterSymbols) : Vi } } - return formatted.fixCursor(buffer.length + groupings, formatterSymbols.grouping) + return transformed.fixCursor(buffer.length + groupings, formatterSymbols.grouping) } - // Called when clicking formatted text + // Called when clicking transformed text // Snaps cursor to the right position // - // the formatted is "1,000" and cursor is placed at the end "1,000|" + // the transformed is "1,000" and cursor is placed at the end "1,000|" // original input is "1000" where cursor should be? - "1000|" override fun transformedToOriginal(offset: Int): Int { + if (offset <= 0) return 0 + if (offset >= transformed.length) return original.length + val grouping = formatterSymbols.grouping.first() - val fixedCursor = formatted.fixCursor(offset, formatterSymbols.grouping) - val addedSymbols = formatted.take(fixedCursor).count { it == grouping } + val fixedCursor = transformed.fixCursor(offset, formatterSymbols.grouping) + val addedSymbols = transformed.take(fixedCursor).count { it == grouping } return fixedCursor - addedSymbols } } diff --git a/core/ui/src/test/java/com/sadellie/unitto/core/ui/ExpressionTransformerTest.kt b/core/ui/src/test/java/com/sadellie/unitto/core/ui/ExpressionTransformerTest.kt new file mode 100644 index 00000000..430a76c3 --- /dev/null +++ b/core/ui/src/test/java/com/sadellie/unitto/core/ui/ExpressionTransformerTest.kt @@ -0,0 +1,68 @@ +/* + * 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.core.ui + +import com.sadellie.unitto.core.ui.common.textfield.ExpressionTransformer +import com.sadellie.unitto.core.ui.common.textfield.FormatterSymbols +import org.junit.Assert.assertEquals +import org.junit.Test + +class ExpressionTransformerTest { + + private val expr = ExpressionTransformer(FormatterSymbols.Comma) + + private fun origToTrans(orig: String, trans: String, offset: Int): Int = + expr.ExpressionMapping(orig, trans).originalToTransformed(offset) + + private fun transToOrig(trans: String, orig: String, offset: Int): Int = + expr.ExpressionMapping(orig, trans).transformedToOriginal(offset) + + @Test + fun `test 1234`() { + // at the start + assertEquals(0, origToTrans("1,234", "1234", 0)) + assertEquals(0, transToOrig("1,234", "1234", 0)) + + // somewhere in inside, no offset needed + assertEquals(1, origToTrans("1234", "1,234", 1)) + assertEquals(1, transToOrig("1,234", "1234", 1)) + + // somewhere in inside, offset needed + assertEquals(1, transToOrig("1,234", "1234", 2)) + + // at the end + assertEquals(5, origToTrans("1234", "1,234", 4)) + assertEquals(4, transToOrig("1,234", "1234", 5)) + } + + @Test + fun `test 123`() { + // at the start + assertEquals(0, origToTrans("123", "123", 0)) + assertEquals(0, transToOrig("123", "123", 0)) + + // somewhere in inside + assertEquals(1, origToTrans("123", "123", 1)) + assertEquals(1, transToOrig("123", "123", 1)) + + // at the end + assertEquals(3, origToTrans("123", "123", 3)) + assertEquals(3, transToOrig("123", "123", 3)) + } +} From 3cfebb343dafe4d5c44569b9aa5177367aaeca2d Mon Sep 17 00:00:00 2001 From: Sad Ellie Date: Wed, 31 May 2023 22:06:15 +0300 Subject: [PATCH 40/51] Fixed history item text length --- .../unitto/feature/calculator/components/HistoryList.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 6666d729..2c7112cd 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 @@ -165,11 +165,11 @@ private fun HistoryListItem( addTokens: (String) -> Unit, ) { val clipboardManager = LocalClipboardManager.current - val expression = historyItem.expression + val expression = historyItem.expression.take(1000) var expressionValue by remember(expression) { mutableStateOf(TextFieldValue(expression, TextRange(expression.length))) } - val result = historyItem.result + val result = historyItem.result.take(1000) var resultValue by remember(result) { mutableStateOf(TextFieldValue(result, TextRange(result.length))) } From d6f8d2e9127ebaf30c77bdf8d4bbe69348b93481 Mon Sep 17 00:00:00 2001 From: Sad Ellie Date: Wed, 31 May 2023 22:16:08 +0300 Subject: [PATCH 41/51] Lilac Luster (Patch 1) --- app/build.gradle.kts | 2 +- fastlane/metadata/android/en-US/changelogs/22.txt | 9 +++++++++ fastlane/metadata/android/ru/changelogs/22.txt | 8 ++++++++ gradle/libs.versions.toml | 2 +- 4 files changed, 19 insertions(+), 2 deletions(-) create mode 100644 fastlane/metadata/android/en-US/changelogs/22.txt create mode 100644 fastlane/metadata/android/ru/changelogs/22.txt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1b63b91b..5042467e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -34,7 +34,7 @@ android { applicationId = "com.sadellie.unitto" minSdk = 21 targetSdk = 33 - versionCode = 21 + versionCode = 22 versionName = "Lilac Luster" } diff --git a/fastlane/metadata/android/en-US/changelogs/22.txt b/fastlane/metadata/android/en-US/changelogs/22.txt new file mode 100644 index 00000000..d1f86cf0 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/22.txt @@ -0,0 +1,9 @@ +"Lilac Luster" update: + +- Non-mathematical percentage support +- New interactive Formatting settings +- Date difference tool +- Improved performance and UI/UX +- Added Italian localization + +Sorry for crashes! \ No newline at end of file diff --git a/fastlane/metadata/android/ru/changelogs/22.txt b/fastlane/metadata/android/ru/changelogs/22.txt new file mode 100644 index 00000000..ef5f499f --- /dev/null +++ b/fastlane/metadata/android/ru/changelogs/22.txt @@ -0,0 +1,8 @@ +Обновление "Lilac Luster": + +- Изменена работа процентов +- Интерактивные настройки форматирования +- Инструмент "Разница между датами" +- Улучшена производительность и внешний вид + +Извините за краши! diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b04ac260..67e1e916 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,5 @@ [versions] -appCode = "21" +appCode = "22" appName = "Lilac Luster" kotlin = "1.8.21" androidxCore = "1.10.0" From f1f1b7841e0327fe395cb0332752f336faf8cf20 Mon Sep 17 00:00:00 2001 From: Sad Ellie Date: Fri, 2 Jun 2023 16:11:15 +0300 Subject: [PATCH 42/51] Free focus on drag start --- .../sadellie/unitto/feature/calculator/CalculatorScreen.kt | 6 ++++++ 1 file changed, 6 insertions(+) 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 bf7fbf4d..51758964 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 @@ -56,6 +56,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.onPlaced import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.TextFieldValue @@ -114,6 +115,7 @@ private fun CalculatorScreen( evaluate: () -> Unit, clearHistory: () -> Unit ) { + val focusManager = LocalFocusManager.current val dragAmount = remember { Animatable(0f) } val dragCoroutineScope = rememberCoroutineScope() val dragAnimSpec = rememberSplineBasedDecay() @@ -183,6 +185,10 @@ private fun CalculatorScreen( dragAmount.snapTo(draggedAmount) } }, + onDragStarted = { + // Moving composables with focus causes performance drop + focusManager.clearFocus(true) + }, onDragStopped = { velocity -> dragCoroutineScope.launch { dragAmount.animateDecay(velocity, dragAnimSpec) From 4354d006529f3ef00c64e22041f4c2c24fee7ade Mon Sep 17 00:00:00 2001 From: Sad Ellie Date: Fri, 2 Jun 2023 16:52:04 +0300 Subject: [PATCH 43/51] I have no idea what i'm doing --- .../unitto/data/model/AbstractUnit.kt | 2 -- .../sadellie/unitto/data/model/DefaultUnit.kt | 3 +++ .../unitto/data/units/AllUnitsRepository.kt | 27 ++++++++++--------- .../data/units/AllUnitsRepositoryTest.kt | 15 ++++++----- .../feature/converter/ConverterViewModel.kt | 6 +---- .../feature/unitslist/LeftSideScreen.kt | 10 +++---- .../feature/unitslist/RightSideScreen.kt | 6 ++--- .../feature/unitslist/UnitsListViewModel.kt | 23 ++++++++-------- .../navigation/UnitsListNavigation.kt | 2 +- 9 files changed, 46 insertions(+), 48 deletions(-) diff --git a/data/model/src/main/java/com/sadellie/unitto/data/model/AbstractUnit.kt b/data/model/src/main/java/com/sadellie/unitto/data/model/AbstractUnit.kt index 3e663423..8ee9aae4 100644 --- a/data/model/src/main/java/com/sadellie/unitto/data/model/AbstractUnit.kt +++ b/data/model/src/main/java/com/sadellie/unitto/data/model/AbstractUnit.kt @@ -37,7 +37,6 @@ import java.math.BigDecimal * @property renderedShortName Used as cache. Stores short name string for this specific device. Need for * search functionality. * @property isFavorite Whether this unit is favorite. - * @property isEnabled Whether we need to show this unit or not * @property pairedUnit Latest paired unit on the right * @property counter The amount of time this unit was chosen */ @@ -50,7 +49,6 @@ abstract class AbstractUnit( var renderedName: String = String(), var renderedShortName: String = String(), var isFavorite: Boolean = false, - var isEnabled: Boolean = true, var pairedUnit: String? = null, var counter: Int = 0 ) { diff --git a/data/model/src/main/java/com/sadellie/unitto/data/model/DefaultUnit.kt b/data/model/src/main/java/com/sadellie/unitto/data/model/DefaultUnit.kt index 789158c6..b37a6e01 100644 --- a/data/model/src/main/java/com/sadellie/unitto/data/model/DefaultUnit.kt +++ b/data/model/src/main/java/com/sadellie/unitto/data/model/DefaultUnit.kt @@ -50,6 +50,9 @@ class DefaultUnit( value: BigDecimal, scale: Int ): BigDecimal { + // Avoid division by zero + if (unitTo.basicUnit.compareTo(BigDecimal.ZERO) == 0) return BigDecimal.ZERO + return this .basicUnit .setScale(MAX_PRECISION) diff --git a/data/units/src/main/java/com/sadellie/unitto/data/units/AllUnitsRepository.kt b/data/units/src/main/java/com/sadellie/unitto/data/units/AllUnitsRepository.kt index 83e22057..4680e432 100644 --- a/data/units/src/main/java/com/sadellie/unitto/data/units/AllUnitsRepository.kt +++ b/data/units/src/main/java/com/sadellie/unitto/data/units/AllUnitsRepository.kt @@ -48,6 +48,9 @@ import com.sadellie.unitto.data.units.collections.temperatureCollection import com.sadellie.unitto.data.units.collections.timeCollection import com.sadellie.unitto.data.units.collections.torqueCollection import com.sadellie.unitto.data.units.collections.volumeCollection +import com.sadellie.unitto.data.units.remote.CurrencyApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import java.math.BigDecimal import javax.inject.Inject import javax.inject.Singleton @@ -123,8 +126,8 @@ class AllUnitsRepository @Inject constructor() { /** * Filter [AllUnitsRepository.allUnits] and group them. * - * @param hideBrokenCurrencies When set to True will remove [AbstractUnit]s that have - * [AbstractUnit.isEnabled] set to False, which means that [AbstractUnit] can not be used. + * @param hideBrokenUnits When set to True will remove [AbstractUnit]s that have + * [AbstractUnit.basicUnit] set to [BigDecimal.ZERO] (comes from currencies API). * @param chosenUnitGroup If provided will scope list to a specific [UnitGroup]. * @param favoritesOnly When True will filter only [AbstractUnit]s with [AbstractUnit.isFavorite] * set to True. @@ -135,7 +138,7 @@ class AllUnitsRepository @Inject constructor() { * @return Grouped by [UnitGroup] list of [AbstractUnit]s. */ fun filterUnits( - hideBrokenCurrencies: Boolean, + hideBrokenUnits: Boolean, chosenUnitGroup: UnitGroup?, favoritesOnly: Boolean, searchQuery: String, @@ -153,8 +156,8 @@ class AllUnitsRepository @Inject constructor() { if (favoritesOnly) { units = units.filter { it.isFavorite } } - if (hideBrokenCurrencies) { - units = units.filter { it.isEnabled } + if (hideBrokenUnits) { + units = units.filter { it.basicUnit > BigDecimal.ZERO } } units = when (sorting) { @@ -198,22 +201,20 @@ class AllUnitsRepository @Inject constructor() { /** * Update [AbstractUnit.basicUnit] properties for currencies from [currencyCollection]. * - * @param conversions Map: [AbstractUnit.unitId] and [BigDecimal] that will replace current - * [AbstractUnit.basicUnit]. + * @param unitFrom Base unit */ - fun updateBasicUnitsForCurrencies( - conversions: Map - ) { + suspend fun updateBasicUnitsForCurrencies( + unitFrom: AbstractUnit + ) = withContext(Dispatchers.IO) { + val conversions: Map = CurrencyApi.retrofitService.getCurrencyPairs(unitFrom.unitId).currency getCollectionByGroup(UnitGroup.CURRENCY).forEach { // Getting rates from map. We set ZERO as default so that it can be skipped val rate = conversions.getOrElse(it.unitId) { BigDecimal.ZERO } // We make sure that we don't divide by zero if (rate > BigDecimal.ZERO) { - it.isEnabled = true it.basicUnit = BigDecimal.ONE.setScale(MAX_PRECISION).div(rate) } else { - // Hiding broken currencies - it.isEnabled = false + it.basicUnit = BigDecimal.ZERO } } } diff --git a/data/units/src/test/java/com/sadellie/unitto/data/units/AllUnitsRepositoryTest.kt b/data/units/src/test/java/com/sadellie/unitto/data/units/AllUnitsRepositoryTest.kt index 6973c59b..ab6b2a74 100644 --- a/data/units/src/test/java/com/sadellie/unitto/data/units/AllUnitsRepositoryTest.kt +++ b/data/units/src/test/java/com/sadellie/unitto/data/units/AllUnitsRepositoryTest.kt @@ -23,6 +23,7 @@ import com.sadellie.unitto.data.model.AbstractUnit import com.sadellie.unitto.data.model.UnitGroup import org.junit.Assert.assertEquals import org.junit.Test +import java.math.BigDecimal class AllUnitsRepositoryTest { @@ -34,7 +35,7 @@ class AllUnitsRepositoryTest { fun filterAllUnitsNoFiltersLeft() { // No filters applied, empty search query, from Left side list val result = allUnitsRepository.filterUnits( - hideBrokenCurrencies = false, + hideBrokenUnits = false, chosenUnitGroup = null, favoritesOnly = false, searchQuery = "", @@ -51,7 +52,7 @@ class AllUnitsRepositoryTest { // All filters applied, from Left side list val result = allUnitsRepository.filterUnits( - hideBrokenCurrencies = false, + hideBrokenUnits = false, chosenUnitGroup = UnitGroup.SPEED, favoritesOnly = true, searchQuery = "kilometer per hour", @@ -68,7 +69,7 @@ class AllUnitsRepositoryTest { fun filterAllUnitsChosenGroupLeft() { // Only specific group is needed, left side screen val result = allUnitsRepository.filterUnits( - hideBrokenCurrencies = false, + hideBrokenUnits = false, chosenUnitGroup = UnitGroup.TIME, favoritesOnly = false, searchQuery = "", @@ -83,7 +84,7 @@ class AllUnitsRepositoryTest { allUnitsRepository.getById(MyUnitIDS.kilometer).isFavorite = true // Only favorite units, left side screen val result = allUnitsRepository.filterUnits( - hideBrokenCurrencies = false, + hideBrokenUnits = false, chosenUnitGroup = null, favoritesOnly = true, searchQuery = "", @@ -101,7 +102,7 @@ class AllUnitsRepositoryTest { // Only search query is entered, other filters are not set, left side screen val result = allUnitsRepository.filterUnits( - hideBrokenCurrencies = false, + hideBrokenUnits = false, chosenUnitGroup = null, favoritesOnly = false, searchQuery = "kilometer per hour", @@ -118,10 +119,10 @@ class AllUnitsRepositoryTest { fun filterAllUnitsHideBrokenCurrencies() { allUnitsRepository .getById(MyUnitIDS.currency_btc) - .apply { isEnabled = false } + .apply { basicUnit = BigDecimal.ZERO } // Hide broken currencies (i.e. cannot be used for conversion at the moment) val result = allUnitsRepository.filterUnits( - hideBrokenCurrencies = true, + hideBrokenUnits = true, chosenUnitGroup = UnitGroup.CURRENCY, favoritesOnly = false, searchQuery = "", diff --git a/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/ConverterViewModel.kt b/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/ConverterViewModel.kt index 15bc07a3..dcd62cbd 100644 --- a/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/ConverterViewModel.kt +++ b/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/ConverterViewModel.kt @@ -37,8 +37,6 @@ import com.sadellie.unitto.data.model.UnitGroup import com.sadellie.unitto.data.units.AllUnitsRepository import com.sadellie.unitto.data.units.MyUnitIDS import com.sadellie.unitto.data.units.combine -import com.sadellie.unitto.data.units.remote.CurrencyApi -import com.sadellie.unitto.data.units.remote.CurrencyUnitResponse import com.sadellie.unitto.data.userprefs.MainPreferences import com.sadellie.unitto.data.userprefs.UserPreferencesRepository import dagger.hilt.android.lifecycle.HiltViewModel @@ -298,9 +296,7 @@ class ConverterViewModel @Inject constructor( _showLoading.update { true } try { - val pairs: CurrencyUnitResponse = - CurrencyApi.retrofitService.getCurrencyPairs(unitFrom.unitId) - allUnitsRepository.updateBasicUnitsForCurrencies(pairs.currency) + allUnitsRepository.updateBasicUnitsForCurrencies(unitFrom) convertAsExpression() } catch (e: Exception) { // Dangerous and stupid, but who cares diff --git a/feature/unitslist/src/main/java/com/sadellie/unitto/feature/unitslist/LeftSideScreen.kt b/feature/unitslist/src/main/java/com/sadellie/unitto/feature/unitslist/LeftSideScreen.kt index 3ed76063..44531ac2 100644 --- a/feature/unitslist/src/main/java/com/sadellie/unitto/feature/unitslist/LeftSideScreen.kt +++ b/feature/unitslist/src/main/java/com/sadellie/unitto/feature/unitslist/LeftSideScreen.kt @@ -94,9 +94,9 @@ internal fun LeftSideScreen( SearchBar( title = stringResource(R.string.units_screen_from), value = uiState.value.searchQuery, - onValueChange = { viewModel.onSearchQueryChange(it) }, + onValueChange = { viewModel.onSearchQueryChange(it, false) }, favoritesOnly = uiState.value.favoritesOnly, - favoriteAction = { viewModel.toggleFavoritesOnly() }, + favoriteAction = { viewModel.toggleFavoritesOnly(false) }, navigateUpAction = navigateUp, focusManager = focusManager, scrollBehavior = scrollBehavior @@ -104,7 +104,7 @@ internal fun LeftSideScreen( ChipsRow( chosenUnitGroup = uiState.value.chosenUnitGroup, items = uiState.value.shownUnitGroups, - selectAction = { viewModel.toggleSelectedChip(it) }, + selectAction = { viewModel.toggleSelectedChip(it, false) }, lazyListState = chipsRowLazyListState, navigateToSettingsAction = navigateToSettingsAction ) @@ -130,7 +130,7 @@ internal fun LeftSideScreen( isSelected = currentUnitId == unit.unitId, selectAction = { selectAction(it) - viewModel.onSearchQueryChange("") + viewModel.onSearchQueryChange("", false) focusManager.clearFocus(true) navigateUp() }, @@ -147,7 +147,7 @@ internal fun LeftSideScreen( if (currentUnitId == null) return@LaunchedEffect // This is still wrong, but works good enough. // Ideally we shouldn't use uiState.value.shownUnitGroups - viewModel.setSelectedChip(currentUnitId) + viewModel.setSelectedChip(currentUnitId, false) val groupToSelect = uiState.value.shownUnitGroups.indexOf(uiState.value.chosenUnitGroup) if (groupToSelect > -1) { chipsRowLazyListState.animateScrollToItem(groupToSelect) diff --git a/feature/unitslist/src/main/java/com/sadellie/unitto/feature/unitslist/RightSideScreen.kt b/feature/unitslist/src/main/java/com/sadellie/unitto/feature/unitslist/RightSideScreen.kt index e07e3eee..d334d101 100644 --- a/feature/unitslist/src/main/java/com/sadellie/unitto/feature/unitslist/RightSideScreen.kt +++ b/feature/unitslist/src/main/java/com/sadellie/unitto/feature/unitslist/RightSideScreen.kt @@ -96,11 +96,11 @@ internal fun RightSideScreen( title = stringResource(R.string.units_screen_to), value = uiState.value.searchQuery, onValueChange = { - viewModel.onSearchQueryChange(it, false) + viewModel.onSearchQueryChange(it, true) }, favoritesOnly = uiState.value.favoritesOnly, favoriteAction = { - viewModel.toggleFavoritesOnly(false) + viewModel.toggleFavoritesOnly(true) }, navigateUpAction = navigateUp, focusManager = focusManager, @@ -127,7 +127,7 @@ internal fun RightSideScreen( isSelected = currentUnit == unit.unitId, selectAction = { selectAction(it) - viewModel.onSearchQueryChange("") + viewModel.onSearchQueryChange("", true) focusManager.clearFocus(true) navigateUp() }, diff --git a/feature/unitslist/src/main/java/com/sadellie/unitto/feature/unitslist/UnitsListViewModel.kt b/feature/unitslist/src/main/java/com/sadellie/unitto/feature/unitslist/UnitsListViewModel.kt index 377517ea..01b5a594 100644 --- a/feature/unitslist/src/main/java/com/sadellie/unitto/feature/unitslist/UnitsListViewModel.kt +++ b/feature/unitslist/src/main/java/com/sadellie/unitto/feature/unitslist/UnitsListViewModel.kt @@ -76,18 +76,18 @@ class UnitsListViewModel @Inject constructor( } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), SecondScreenUIState()) - fun toggleFavoritesOnly(hideBrokenCurrencies: Boolean = true) { + fun toggleFavoritesOnly(hideBrokenUnits: Boolean) { viewModelScope.launch { userPrefsRepository.updateUnitConverterFavoritesOnly( !_userPrefs.value.unitConverterFavoritesOnly ) - loadUnitsToShow(hideBrokenCurrencies) + loadUnitsToShow(hideBrokenUnits) } } - fun onSearchQueryChange(newValue: String, hideBrokenCurrencies: Boolean = true) { + fun onSearchQueryChange(newValue: String, hideBrokenUnits: Boolean) { _searchQuery.update { newValue } - loadUnitsToShow(hideBrokenCurrencies) + loadUnitsToShow(hideBrokenUnits) } /** @@ -95,9 +95,9 @@ class UnitsListViewModel @Inject constructor( * * @param unit Will find group for unit with this id. */ - fun setSelectedChip(unit: String, hideBrokenCurrencies: Boolean = true) { + fun setSelectedChip(unit: String, hideBrokenUnits: Boolean) { _chosenUnitGroup.update { allUnitsRepository.getById(unit).group } - loadUnitsToShow(hideBrokenCurrencies) + loadUnitsToShow(hideBrokenUnits) } /** @@ -108,27 +108,26 @@ class UnitsListViewModel @Inject constructor( * * @param unitGroup [UnitGroup], currently selected chip. */ - fun toggleSelectedChip(unitGroup: UnitGroup, hideBrokenCurrencies: Boolean = true) { + fun toggleSelectedChip(unitGroup: UnitGroup, hideBrokenUnits: Boolean) { val newUnitGroup = if (_chosenUnitGroup.value == unitGroup) null else unitGroup _chosenUnitGroup.update { newUnitGroup } - loadUnitsToShow(hideBrokenCurrencies) + loadUnitsToShow(hideBrokenUnits) } /** * Filters and groups [AllUnitsRepository.allUnits] in coroutine * - * @param hideBrokenCurrencies Decide whether or not we are on left side. Need it because right side requires - * us to mark disabled currency units + * @param hideBrokenUnits Broken units come from currencies API (basic unit is zero) */ private fun loadUnitsToShow( - hideBrokenCurrencies: Boolean + hideBrokenUnits: Boolean ) { viewModelScope.launch { // This is mostly not UI related stuff and viewModelScope.launch uses Dispatchers.Main // So we switch to Default withContext(Dispatchers.Default) { val unitsToShow = allUnitsRepository.filterUnits( - hideBrokenCurrencies = hideBrokenCurrencies, + hideBrokenUnits = hideBrokenUnits, chosenUnitGroup = _chosenUnitGroup.value, favoritesOnly = _userPrefs.value.unitConverterFavoritesOnly, searchQuery = _searchQuery.value, diff --git a/feature/unitslist/src/main/java/com/sadellie/unitto/feature/unitslist/navigation/UnitsListNavigation.kt b/feature/unitslist/src/main/java/com/sadellie/unitto/feature/unitslist/navigation/UnitsListNavigation.kt index 5692e92b..9c779305 100644 --- a/feature/unitslist/src/main/java/com/sadellie/unitto/feature/unitslist/navigation/UnitsListNavigation.kt +++ b/feature/unitslist/src/main/java/com/sadellie/unitto/feature/unitslist/navigation/UnitsListNavigation.kt @@ -73,7 +73,7 @@ fun NavGraphBuilder.rightScreen( val unitFromId = it.arguments?.getString(unitFromIdArg) ?: return@composable val unitToId = it.arguments?.getString(unitToIdArg) ?: return@composable val input = it.arguments?.getString(inputArg) - viewModel.setSelectedChip(unitFromId, false) + viewModel.setSelectedChip(unitFromId, true) RightSideScreen( viewModel = viewModel, From 627ce5a78567149993d085aea674c353cef4aa26 Mon Sep 17 00:00:00 2001 From: sadellie Date: Thu, 20 Jul 2023 22:12:33 +0300 Subject: [PATCH 44/51] Save pair on all unit changes --- .../sadellie/unitto/feature/converter/ConverterViewModel.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/ConverterViewModel.kt b/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/ConverterViewModel.kt index dcd62cbd..2d44929d 100644 --- a/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/ConverterViewModel.kt +++ b/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/ConverterViewModel.kt @@ -160,8 +160,8 @@ class ConverterViewModel @Inject constructor( _unitTo.update { allUnitsRepository.getCollectionByGroup(unit.group).first() } } incrementCounter(unit) - updateCurrenciesRatesIfNeeded() saveLatestPairOfUnits() + updateCurrenciesRatesIfNeeded() } /** @@ -182,6 +182,7 @@ class ConverterViewModel @Inject constructor( _unitFrom .getAndUpdate { _unitTo.value } .also { oldUnitFrom -> _unitTo.update { oldUnitFrom } } + saveLatestPairOfUnits() updateCurrenciesRatesIfNeeded() } From 4e28521ec0fd53c88622948d840f5a59939d5e00 Mon Sep 17 00:00:00 2001 From: sadellie Date: Thu, 20 Jul 2023 22:25:15 +0300 Subject: [PATCH 45/51] Decimal percentages closes #68 --- .../src/main/java/io/github/sadellie/evaluatto/Tokenizer.kt | 4 ++-- .../test/java/io/github/sadellie/evaluatto/FixLexiconTest.kt | 4 ++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/data/evaluatto/src/main/java/io/github/sadellie/evaluatto/Tokenizer.kt b/data/evaluatto/src/main/java/io/github/sadellie/evaluatto/Tokenizer.kt index 9c45dc4d..03da1154 100644 --- a/data/evaluatto/src/main/java/io/github/sadellie/evaluatto/Tokenizer.kt +++ b/data/evaluatto/src/main/java/io/github/sadellie/evaluatto/Tokenizer.kt @@ -197,7 +197,7 @@ class Tokenizer(private val streamOfTokens: String) { } private fun List.getNumberOrExpressionBefore(pos: Int): List { - val digits = Token.Digit.all.map { it[0] } + val digits = Token.Digit.allWithDot.map { it[0] } val tokenInFront = this[pos - 1] @@ -205,7 +205,7 @@ class Tokenizer(private val streamOfTokens: String) { if (tokenInFront.all { it in digits }) return listOf(tokenInFront) // Not just a number. Probably expression in brackets. - if (tokenInFront != Token.Operator.rightBracket) throw Exception("Unexpected token before the percentage") + if (tokenInFront != Token.Operator.rightBracket) throw Exception("Unexpected token before percentage") // Start walking left until we get balanced brackets var cursor = pos - 1 diff --git a/data/evaluatto/src/test/java/io/github/sadellie/evaluatto/FixLexiconTest.kt b/data/evaluatto/src/test/java/io/github/sadellie/evaluatto/FixLexiconTest.kt index 4a3f3ffa..fd8f8331 100644 --- a/data/evaluatto/src/test/java/io/github/sadellie/evaluatto/FixLexiconTest.kt +++ b/data/evaluatto/src/test/java/io/github/sadellie/evaluatto/FixLexiconTest.kt @@ -120,5 +120,9 @@ class FixLexiconTest { ) assertLex("(80÷100)×(80÷100)", "80%80%") + + assertLex("10+(2.0÷100×(10))", "10+2.0%") + + assertLex("10+(2.÷100×(10))", "10+2.%") } } From eb9b12da28677aab8f9bbb8d61418a2020c5e149 Mon Sep 17 00:00:00 2001 From: sadellie Date: Thu, 20 Jul 2023 22:52:53 +0300 Subject: [PATCH 46/51] Retry currency rates update relevant to #31 --- core/base/src/main/res/values/strings.xml | 1 + .../unitto/feature/converter/ConverterScreen.kt | 6 +++++- .../unitto/feature/converter/ConverterViewModel.kt | 2 +- .../feature/converter/components/TopScreen.kt | 14 +++++++++++--- 4 files changed, 18 insertions(+), 5 deletions(-) diff --git a/core/base/src/main/res/values/strings.xml b/core/base/src/main/res/values/strings.xml index 451ab317..2b454edd 100644 --- a/core/base/src/main/res/values/strings.xml +++ b/core/base/src/main/res/values/strings.xml @@ -962,6 +962,7 @@ "Loading…" "Error" + "Click to try again" "Copied %1$s!" "Cancel" "OK" diff --git a/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/ConverterScreen.kt b/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/ConverterScreen.kt index be321efc..7f27e604 100644 --- a/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/ConverterScreen.kt +++ b/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/ConverterScreen.kt @@ -61,6 +61,7 @@ internal fun ConverterRoute( clearInput = viewModel::clearInput, onCursorChange = viewModel::onCursorChange, cutCallback = viewModel::deleteTokens, + onErrorClick = viewModel::updateCurrenciesRatesIfNeeded, ) } @@ -77,6 +78,7 @@ private fun ConverterScreen( clearInput: () -> Unit, onCursorChange: (TextRange) -> Unit, cutCallback: () -> Unit, + onErrorClick: () -> Unit, ) { UnittoScreenWithTopBar( title = { Text(stringResource(R.string.unit_converter)) }, @@ -107,6 +109,7 @@ private fun ConverterScreen( cutCallback = cutCallback, pasteCallback = processInput, formatterSymbols = uiState.formatterSymbols, + onErrorClick = onErrorClick ) }, content2 = { @@ -144,6 +147,7 @@ private fun PreviewConverterScreen() { deleteDigit = {}, clearInput = {}, onCursorChange = {}, - cutCallback = {} + cutCallback = {}, + onErrorClick = {}, ) } diff --git a/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/ConverterViewModel.kt b/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/ConverterViewModel.kt index 2d44929d..016bfe31 100644 --- a/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/ConverterViewModel.kt +++ b/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/ConverterViewModel.kt @@ -285,7 +285,7 @@ class ConverterViewModel @Inject constructor( } } - private fun updateCurrenciesRatesIfNeeded() { + fun updateCurrenciesRatesIfNeeded() { viewModelScope.launch(Dispatchers.IO) { _showError.update { false } _showLoading.update { false } diff --git a/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/components/TopScreen.kt b/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/components/TopScreen.kt index 31507ee5..d2cc56d5 100644 --- a/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/components/TopScreen.kt +++ b/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/components/TopScreen.kt @@ -98,6 +98,7 @@ internal fun TopScreenPart( cutCallback: () -> Unit, pasteCallback: (String) -> Unit, formatterSymbols: FormatterSymbols, + onErrorClick: () -> Unit, ) { var swapped by remember { mutableStateOf(false) } val swapButtonRotation: Float by animateFloatAsState( @@ -148,7 +149,8 @@ internal fun TopScreenPart( modifier = Modifier, value = calculatedTextFieldValue, onCursorChange = { newSelection -> - calculatedTextFieldValue = calculatedTextFieldValue.copy(selection = newSelection) + calculatedTextFieldValue = + calculatedTextFieldValue.copy(selection = newSelection) }, formatterSymbols = formatterSymbols, textColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), @@ -237,7 +239,7 @@ internal fun TopScreenPart( UnformattedTextField( modifier = Modifier.weight(2f), value = TextFieldValue(stringResource(R.string.error_label)), - onCursorChange = {}, + onCursorChange = { onErrorClick() }, minRatio = 0.7f, readOnly = true, textColor = MaterialTheme.colorScheme.error @@ -245,9 +247,15 @@ internal fun TopScreenPart( } } + val supportLabelTo = when { + outputValue is ConversionResult.Error -> R.string.try_again_label + (unitTo?.shortName != null) -> unitTo.shortName + else -> R.string.loading_label + } + AnimatedContent( modifier = Modifier.fillMaxWidth(), - targetState = stringResource(unitTo?.shortName ?: R.string.loading_label), + targetState = stringResource(supportLabelTo), transitionSpec = { // Enter animation (expandHorizontally(clip = false, expandFrom = Alignment.Start) + fadeIn() From 7c70258b8436d8569a0bd8726b1bc0f1f10d5644 Mon Sep 17 00:00:00 2001 From: sadellie Date: Fri, 21 Jul 2023 21:38:07 +0300 Subject: [PATCH 47/51] Bumpy bumps Updated navigation logic --- app/build.gradle.kts | 4 +- .../java/com/sadellie/unitto/UnittoApp.kt | 47 ++--- .../com/sadellie/unitto/UnittoNavigation.kt | 17 +- .../src/main/java/UnittoHiltPlugin.kt | 1 + .../main/java/UnittoLibraryComposePlugin.kt | 1 + .../main/java/UnittoLibraryFeaturePlugin.kt | 1 + .../src/main/java/UnittoLibraryPlugin.kt | 1 + .../sadellie/unitto/ConfigureKotlinAndroid.kt | 2 +- core/ui/build.gradle.kts | 1 - .../core/ui/common/UnittoNavigationDrawer.kt | 195 ++++++++++++++++++ .../unitto/core/ui/common/UnittoSlider.kt | 14 +- .../core/ui/ExpressionTransformerTest.kt | 4 +- .../unitto/data/common/IsExpressionText.kt | 4 +- .../unitto/data/epoch/DateToEpochTest.kt | 4 +- .../evaluatto/ExpressionComplexTest.kt | 2 +- .../evaluatto/ExpressionExceptionsTest.kt | 2 +- .../evaluatto/ExpressionSimpleTest.kt | 2 +- .../sadellie/evaluatto/FixLexiconTest.kt | 3 +- .../io/github/sadellie/evaluatto/Helpers.kt | 11 +- .../sadellie/evaluatto/TokenizerTest.kt | 3 +- .../data/units/AllUnitsRepositoryTest.kt | 4 +- .../unitto/data/units/AllUnitsTest.kt | 11 +- .../units/LevenshteinFilterAndSortTest.kt | 4 +- .../unitto/data/units/LevenshteinTest.kt | 4 +- .../data/units/MinimumRequiredScaleTest.kt | 4 +- .../feature/calculator/CalculatorScreen.kt | 5 +- feature/converter/build.gradle.kts | 1 - .../feature/converter/CoroutinesTestUtils.kt | 44 ---- .../datedifference/DateDifferenceKtTest.kt | 4 +- .../unitto/feature/settings/AboutScreen.kt | 3 +- .../settings/formatting/FormattingScreen.kt | 10 +- .../settings/navigation/SettingsNavigation.kt | 5 +- gradle/libs.versions.toml | 36 ++-- 33 files changed, 297 insertions(+), 157 deletions(-) create mode 100644 core/ui/src/main/java/com/sadellie/unitto/core/ui/common/UnittoNavigationDrawer.kt delete mode 100644 feature/converter/src/test/java/com/sadellie/unitto/feature/converter/CoroutinesTestUtils.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5042467e..60cbdb2e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -28,12 +28,12 @@ plugins { android { namespace = "com.sadellie.unitto" - compileSdk = 33 + compileSdk = 34 defaultConfig { applicationId = "com.sadellie.unitto" minSdk = 21 - targetSdk = 33 + targetSdk = 34 versionCode = 22 versionName = "Lilac Luster" } diff --git a/app/src/main/java/com/sadellie/unitto/UnittoApp.kt b/app/src/main/java/com/sadellie/unitto/UnittoApp.kt index 59ce7f13..131b7a64 100644 --- a/app/src/main/java/com/sadellie/unitto/UnittoApp.kt +++ b/app/src/main/java/com/sadellie/unitto/UnittoApp.kt @@ -19,10 +19,8 @@ package com.sadellie.unitto import androidx.compose.animation.core.tween -import androidx.compose.material3.DrawerValue +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalNavigationDrawer -import androidx.compose.material3.rememberDrawerState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf @@ -40,6 +38,10 @@ import androidx.navigation.compose.rememberNavController import com.google.accompanist.systemuicontroller.rememberSystemUiController import com.sadellie.unitto.core.base.TopLevelDestinations import com.sadellie.unitto.core.ui.common.UnittoDrawerSheet +import com.sadellie.unitto.core.ui.common.UnittoModalNavigationDrawer +import com.sadellie.unitto.core.ui.common.close +import com.sadellie.unitto.core.ui.common.open +import com.sadellie.unitto.core.ui.common.rememberUnittoDrawerState import com.sadellie.unitto.core.ui.model.DrawerItems import com.sadellie.unitto.core.ui.theme.AppTypography import com.sadellie.unitto.core.ui.theme.DarkThemeColors @@ -49,13 +51,13 @@ import io.github.sadellie.themmo.Themmo import io.github.sadellie.themmo.rememberThemmoController import kotlinx.coroutines.launch +@OptIn(ExperimentalFoundationApi::class) @Composable internal fun UnittoApp(uiPrefs: UIPreferences) { val themmoController = rememberThemmoController( lightColorScheme = LightThemeColors, darkColorScheme = DarkThemeColors, - // Anything below will not be called if theming mode is still loading from DataStore themingMode = uiPrefs.themingMode, dynamicThemeEnabled = uiPrefs.enableDynamicTheme, amoledThemeEnabled = uiPrefs.enableAmoledTheme, @@ -66,7 +68,7 @@ internal fun UnittoApp(uiPrefs: UIPreferences) { val sysUiController = rememberSystemUiController() // Navigation drawer stuff - val drawerState = rememberDrawerState(DrawerValue.Closed) + val drawerState = rememberUnittoDrawerState() val drawerScope = rememberCoroutineScope() val mainTabs = listOf( DrawerItems.Calculator, @@ -88,14 +90,6 @@ internal fun UnittoApp(uiPrefs: UIPreferences) { } } } - val gesturesEnabled: Boolean by remember(navBackStackEntry?.destination) { - derivedStateOf { - // Will be true for routes like - // [null, calculator_route, settings_graph, settings_route, themes_route] - // We disable drawer drag gesture when we are too deep - navController.backQueue.size <= 4 - } - } Themmo( themmoController = themmoController, @@ -107,10 +101,8 @@ internal fun UnittoApp(uiPrefs: UIPreferences) { mutableStateOf(backgroundColor.luminance() > 0.5f) } - ModalNavigationDrawer( - drawerState = drawerState, - gesturesEnabled = gesturesEnabled, - drawerContent = { + UnittoModalNavigationDrawer( + drawer = { UnittoDrawerSheet( modifier = Modifier, mainTabs = mainTabs, @@ -126,15 +118,20 @@ internal fun UnittoApp(uiPrefs: UIPreferences) { restoreState = true } } + }, + modifier = Modifier, + state = drawerState, + gesturesEnabled = true, + scope = drawerScope, + content = { + UnittoNavigation( + navController = navController, + themmoController = it, + startDestination = uiPrefs.startingScreen, + openDrawer = { drawerScope.launch { drawerState.open() } } + ) } - ) { - UnittoNavigation( - navController = navController, - themmoController = it, - startDestination = uiPrefs.startingScreen, - openDrawer = { drawerScope.launch { drawerState.open() } } - ) - } + ) LaunchedEffect(useDarkIcons) { sysUiController.setNavigationBarColor(Color.Transparent, useDarkIcons) diff --git a/app/src/main/java/com/sadellie/unitto/UnittoNavigation.kt b/app/src/main/java/com/sadellie/unitto/UnittoNavigation.kt index 8e5fcf8d..7e19b4ae 100644 --- a/app/src/main/java/com/sadellie/unitto/UnittoNavigation.kt +++ b/app/src/main/java/com/sadellie/unitto/UnittoNavigation.kt @@ -23,7 +23,6 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.hilt.navigation.compose.hiltViewModel -import androidx.navigation.NavGraph.Companion.findStartDestination import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import com.sadellie.unitto.feature.calculator.navigation.calculatorScreen @@ -55,20 +54,10 @@ internal fun UnittoNavigation( startDestination = startDestination, modifier = Modifier.background(MaterialTheme.colorScheme.background) ) { - fun navigateToSettings() { - navController.navigateToSettings { - popUpTo(navController.graph.findStartDestination().id) { - saveState = true - } - launchSingleTop = true - restoreState = true - } - } - converterScreen( navigateToLeftScreen = navController::navigateToLeftSide, navigateToRightScreen = navController::navigateToRightSide, - navigateToSettings = ::navigateToSettings, + navigateToSettings = navController::navigateToSettings, navigateToMenu = openDrawer, viewModel = converterViewModel ) @@ -95,12 +84,12 @@ internal fun UnittoNavigation( calculatorScreen( navigateToMenu = openDrawer, - navigateToSettings = ::navigateToSettings + navigateToSettings = navController::navigateToSettings ) dateDifferenceScreen( navigateToMenu = openDrawer, - navigateToSettings = ::navigateToSettings + navigateToSettings = navController::navigateToSettings ) } } diff --git a/build-logic/convention/src/main/java/UnittoHiltPlugin.kt b/build-logic/convention/src/main/java/UnittoHiltPlugin.kt index f883aa1d..be0545f1 100644 --- a/build-logic/convention/src/main/java/UnittoHiltPlugin.kt +++ b/build-logic/convention/src/main/java/UnittoHiltPlugin.kt @@ -22,6 +22,7 @@ import org.gradle.api.artifacts.VersionCatalogsExtension import org.gradle.kotlin.dsl.dependencies import org.gradle.kotlin.dsl.getByType +@Suppress("UNUSED") class UnittoHiltPlugin : Plugin { override fun apply(target: Project) { with(target) { diff --git a/build-logic/convention/src/main/java/UnittoLibraryComposePlugin.kt b/build-logic/convention/src/main/java/UnittoLibraryComposePlugin.kt index 37a429f2..d7670ac2 100644 --- a/build-logic/convention/src/main/java/UnittoLibraryComposePlugin.kt +++ b/build-logic/convention/src/main/java/UnittoLibraryComposePlugin.kt @@ -25,6 +25,7 @@ import org.gradle.kotlin.dsl.configure import org.gradle.kotlin.dsl.dependencies import org.gradle.kotlin.dsl.getByType +@Suppress("UNUSED") class UnittoLibraryComposePlugin : Plugin { override fun apply(target: Project) { with(target) { diff --git a/build-logic/convention/src/main/java/UnittoLibraryFeaturePlugin.kt b/build-logic/convention/src/main/java/UnittoLibraryFeaturePlugin.kt index 49b4e58d..26c031fb 100644 --- a/build-logic/convention/src/main/java/UnittoLibraryFeaturePlugin.kt +++ b/build-logic/convention/src/main/java/UnittoLibraryFeaturePlugin.kt @@ -22,6 +22,7 @@ import org.gradle.api.artifacts.VersionCatalogsExtension import org.gradle.kotlin.dsl.dependencies import org.gradle.kotlin.dsl.getByType +@Suppress("UNUSED") class UnittoLibraryFeaturePlugin : Plugin { override fun apply(target: Project) { with(target) { diff --git a/build-logic/convention/src/main/java/UnittoLibraryPlugin.kt b/build-logic/convention/src/main/java/UnittoLibraryPlugin.kt index 4e1bc798..b0189e6c 100644 --- a/build-logic/convention/src/main/java/UnittoLibraryPlugin.kt +++ b/build-logic/convention/src/main/java/UnittoLibraryPlugin.kt @@ -25,6 +25,7 @@ import org.gradle.kotlin.dsl.configure import org.gradle.kotlin.dsl.dependencies import org.gradle.kotlin.dsl.getByType +@Suppress("UNUSED") class UnittoLibraryPlugin : Plugin { override fun apply(target: Project) { with(target) { diff --git a/build-logic/convention/src/main/java/com/sadellie/unitto/ConfigureKotlinAndroid.kt b/build-logic/convention/src/main/java/com/sadellie/unitto/ConfigureKotlinAndroid.kt index 0a63a567..99c0f09b 100644 --- a/build-logic/convention/src/main/java/com/sadellie/unitto/ConfigureKotlinAndroid.kt +++ b/build-logic/convention/src/main/java/com/sadellie/unitto/ConfigureKotlinAndroid.kt @@ -34,7 +34,7 @@ internal fun Project.configureKotlinAndroid( commonExtension: CommonExtension<*, *, *, *>, ) { commonExtension.apply { - compileSdk = 33 + compileSdk = 34 defaultConfig { minSdk = 21 diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts index 681bdca9..51d74dca 100644 --- a/core/ui/build.gradle.kts +++ b/core/ui/build.gradle.kts @@ -37,7 +37,6 @@ android { dependencies { testImplementation(libs.junit) - testImplementation(libs.org.robolectric) testImplementation(libs.androidx.compose.ui.test.junit4) debugImplementation(libs.androidx.compose.ui.test.manifest) diff --git a/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/UnittoNavigationDrawer.kt b/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/UnittoNavigationDrawer.kt new file mode 100644 index 00000000..08fd55ac --- /dev/null +++ b/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/UnittoNavigationDrawer.kt @@ -0,0 +1,195 @@ +/* + * 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.core.ui.common + +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.gestures.AnchoredDraggableState +import androidx.compose.foundation.gestures.DraggableAnchors +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.anchoredDraggable +import androidx.compose.foundation.gestures.animateTo +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.DrawerDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import com.sadellie.unitto.core.ui.model.DrawerItems +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import kotlin.math.roundToInt + +// Why do I have to do it myself? +@Composable +fun UnittoModalNavigationDrawer( + drawer: @Composable () -> Unit, + modifier: Modifier, + state: AnchoredDraggableState, + gesturesEnabled: Boolean, + scope: CoroutineScope, + content: @Composable () -> Unit, +) { + Box(modifier.fillMaxSize()) { + content() + + Scrim( + open = state.isOpen, + onClose = { if (gesturesEnabled) scope.launch { state.close() } }, + fraction = { + fraction(state.anchors.minAnchor(), state.anchors.maxAnchor(), state.offset) + }, + color = DrawerDefaults.scrimColor + ) + + // Drawer + Box(Modifier + .offset { + IntOffset( + x = state + .requireOffset() + .roundToInt(), y = 0 + ) + } + .anchoredDraggable( + state = state, + orientation = Orientation.Horizontal, + enabled = gesturesEnabled or state.isOpen, + ) + .padding(end = 18.dp) // Draggable when closed + ) { + drawer() + } + + } +} + +@Composable +private fun Scrim( + open: Boolean, + onClose: () -> Unit, + fraction: () -> Float, + color: Color, +) { + val dismissDrawer = if (open) { + Modifier.pointerInput(onClose) { detectTapGestures { onClose() } } + } else { + Modifier + } + + Canvas( + Modifier + .fillMaxSize() + .then(dismissDrawer) + ) { + drawRect(color, alpha = fraction()) + } +} + +enum class UnittoDrawerState { OPEN, CLOSED } + +@Composable +fun rememberUnittoDrawerState( + initialValue: UnittoDrawerState = UnittoDrawerState.CLOSED, +): AnchoredDraggableState { + val minValue = -with(LocalDensity.current) { 360.dp.toPx() } + val positionalThreshold = -minValue * 0.5f + val velocityThreshold = with(LocalDensity.current) { 400.dp.toPx() } + + return remember { + AnchoredDraggableState( + initialValue = initialValue, + anchors = DraggableAnchors { + UnittoDrawerState.OPEN at 0F + UnittoDrawerState.CLOSED at minValue + }, + positionalThreshold = { positionalThreshold }, + velocityThreshold = { velocityThreshold }, + animationSpec = tween() + ) + } +} + +private val AnchoredDraggableState.isOpen + get() = this.currentValue == UnittoDrawerState.OPEN + +suspend fun AnchoredDraggableState.close() { + this.animateTo(UnittoDrawerState.CLOSED) +} + +suspend fun AnchoredDraggableState.open() { + this.animateTo(UnittoDrawerState.OPEN) +} + +private fun fraction(a: Float, b: Float, pos: Float) = + ((pos - a) / (b - a)).coerceIn(0f, 1f) + +@Preview(backgroundColor = 0xFFC8F7D4, showBackground = true, showSystemUi = true) +@Composable +private fun PreviewUnittoModalNavigationDrawer() { + val drawerState = rememberUnittoDrawerState(initialValue = UnittoDrawerState.OPEN) + val corScope = rememberCoroutineScope() + + UnittoModalNavigationDrawer( + drawer = { + UnittoDrawerSheet( + modifier = Modifier, + mainTabs = listOf( + DrawerItems.Calculator, + DrawerItems.Calculator, + DrawerItems.Calculator, + ), + additionalTabs = listOf( + DrawerItems.Calculator, + DrawerItems.Calculator, + DrawerItems.Calculator, + ), + currentDestination = DrawerItems.Calculator.destination, + onItemClick = {} + ) + }, + modifier = Modifier, + state = drawerState, + gesturesEnabled = true, + scope = corScope, + content = { + Column { + Text(text = "Content") + Button( + onClick = { corScope.launch { drawerState.open() } } + ) { + Text(text = "BUTTON") + } + } + } + ) +} diff --git a/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/UnittoSlider.kt b/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/UnittoSlider.kt index 73fc9141..d4e18137 100644 --- a/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/UnittoSlider.kt +++ b/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/UnittoSlider.kt @@ -26,11 +26,11 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Slider -import androidx.compose.material3.SliderPositions +import androidx.compose.material3.SliderState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue @@ -72,18 +72,18 @@ fun UnittoSlider( @Composable private fun SquigglyTrack( - sliderPosition: SliderPositions, + sliderState: SliderState, eachWaveWidth: Float = 80f, strokeWidth: Float = 15f, filledColor: Color = MaterialTheme.colorScheme.primary, unfilledColor: Color = MaterialTheme.colorScheme.surfaceVariant ) { val coroutineScope = rememberCoroutineScope() - var direct by remember { mutableStateOf(0.72f) } + var direct by remember { mutableFloatStateOf(0.72f) } val animatedDirect = animateFloatAsState(direct, spring(stiffness = Spring.StiffnessLow)) - val slider = sliderPosition.activeRange.endInclusive + val slider = sliderState.valueRange.endInclusive - LaunchedEffect(sliderPosition.activeRange.endInclusive) { + LaunchedEffect(sliderState.valueRange.endInclusive) { coroutineScope.launch { delay(200L) direct *= -1 @@ -148,7 +148,7 @@ private fun SquigglyTrack( @Preview(device = "spec:width=1920dp,height=1080dp,dpi=480") @Composable private fun PreviewNewSlider() { - var currentValue by remember { mutableStateOf(0.9f) } + var currentValue by remember { mutableFloatStateOf(0.9f) } UnittoSlider( value = currentValue, diff --git a/core/ui/src/test/java/com/sadellie/unitto/core/ui/ExpressionTransformerTest.kt b/core/ui/src/test/java/com/sadellie/unitto/core/ui/ExpressionTransformerTest.kt index 430a76c3..2e67a81e 100644 --- a/core/ui/src/test/java/com/sadellie/unitto/core/ui/ExpressionTransformerTest.kt +++ b/core/ui/src/test/java/com/sadellie/unitto/core/ui/ExpressionTransformerTest.kt @@ -20,8 +20,8 @@ package com.sadellie.unitto.core.ui import com.sadellie.unitto.core.ui.common.textfield.ExpressionTransformer import com.sadellie.unitto.core.ui.common.textfield.FormatterSymbols -import org.junit.Assert.assertEquals -import org.junit.Test +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test class ExpressionTransformerTest { diff --git a/data/common/src/test/java/com/sadellie/unitto/data/common/IsExpressionText.kt b/data/common/src/test/java/com/sadellie/unitto/data/common/IsExpressionText.kt index 2f581428..28a5ec48 100644 --- a/data/common/src/test/java/com/sadellie/unitto/data/common/IsExpressionText.kt +++ b/data/common/src/test/java/com/sadellie/unitto/data/common/IsExpressionText.kt @@ -18,8 +18,8 @@ package com.sadellie.unitto.data.common -import org.junit.Assert.assertEquals -import org.junit.Test +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test class IsExpressionText { diff --git a/data/epoch/src/test/java/com/sadellie/unitto/data/epoch/DateToEpochTest.kt b/data/epoch/src/test/java/com/sadellie/unitto/data/epoch/DateToEpochTest.kt index 5a4ee4f7..30b963a2 100644 --- a/data/epoch/src/test/java/com/sadellie/unitto/data/epoch/DateToEpochTest.kt +++ b/data/epoch/src/test/java/com/sadellie/unitto/data/epoch/DateToEpochTest.kt @@ -18,8 +18,8 @@ package com.sadellie.unitto.data.epoch -import org.junit.Assert.assertEquals -import org.junit.Test +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test class DateToEpochTest { diff --git a/data/evaluatto/src/test/java/io/github/sadellie/evaluatto/ExpressionComplexTest.kt b/data/evaluatto/src/test/java/io/github/sadellie/evaluatto/ExpressionComplexTest.kt index d530ebd6..f7ab5edb 100644 --- a/data/evaluatto/src/test/java/io/github/sadellie/evaluatto/ExpressionComplexTest.kt +++ b/data/evaluatto/src/test/java/io/github/sadellie/evaluatto/ExpressionComplexTest.kt @@ -18,7 +18,7 @@ package io.github.sadellie.evaluatto -import org.junit.Test +import org.junit.jupiter.api.Test class ExpressionComplexTest { diff --git a/data/evaluatto/src/test/java/io/github/sadellie/evaluatto/ExpressionExceptionsTest.kt b/data/evaluatto/src/test/java/io/github/sadellie/evaluatto/ExpressionExceptionsTest.kt index 262c5841..78ffeb83 100644 --- a/data/evaluatto/src/test/java/io/github/sadellie/evaluatto/ExpressionExceptionsTest.kt +++ b/data/evaluatto/src/test/java/io/github/sadellie/evaluatto/ExpressionExceptionsTest.kt @@ -18,7 +18,7 @@ package io.github.sadellie.evaluatto -import org.junit.Test +import org.junit.jupiter.api.Test class ExpressionExceptionsTest { diff --git a/data/evaluatto/src/test/java/io/github/sadellie/evaluatto/ExpressionSimpleTest.kt b/data/evaluatto/src/test/java/io/github/sadellie/evaluatto/ExpressionSimpleTest.kt index 24d0b75e..0e1f68f8 100644 --- a/data/evaluatto/src/test/java/io/github/sadellie/evaluatto/ExpressionSimpleTest.kt +++ b/data/evaluatto/src/test/java/io/github/sadellie/evaluatto/ExpressionSimpleTest.kt @@ -18,7 +18,7 @@ package io.github.sadellie.evaluatto -import org.junit.Test +import org.junit.jupiter.api.Test class ExpressionSimpleTest { diff --git a/data/evaluatto/src/test/java/io/github/sadellie/evaluatto/FixLexiconTest.kt b/data/evaluatto/src/test/java/io/github/sadellie/evaluatto/FixLexiconTest.kt index fd8f8331..b6b9d8e6 100644 --- a/data/evaluatto/src/test/java/io/github/sadellie/evaluatto/FixLexiconTest.kt +++ b/data/evaluatto/src/test/java/io/github/sadellie/evaluatto/FixLexiconTest.kt @@ -18,9 +18,10 @@ package io.github.sadellie.evaluatto -import org.junit.Test +import org.junit.jupiter.api.Test class FixLexiconTest { + @Test fun `missing multiply`() { assertLex( diff --git a/data/evaluatto/src/test/java/io/github/sadellie/evaluatto/Helpers.kt b/data/evaluatto/src/test/java/io/github/sadellie/evaluatto/Helpers.kt index 1651ff2e..806c42e7 100644 --- a/data/evaluatto/src/test/java/io/github/sadellie/evaluatto/Helpers.kt +++ b/data/evaluatto/src/test/java/io/github/sadellie/evaluatto/Helpers.kt @@ -18,12 +18,13 @@ package io.github.sadellie.evaluatto -import org.junit.Assert +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertThrows import java.math.BigDecimal import java.math.RoundingMode fun assertExpr(expr: String, result: String, radianMode: Boolean = true) = - Assert.assertEquals( + assertEquals( BigDecimal(result).setScale(10, RoundingMode.HALF_EVEN), Expression(expr, radianMode).calculate().setScale(10, RoundingMode.HALF_EVEN) ) @@ -33,13 +34,13 @@ fun assertExprFail( expr: String, radianMode: Boolean = true ) { - Assert.assertThrows(expectedThrowable) { + assertThrows(expectedThrowable) { Expression(expr, radianMode = radianMode).calculate() } } fun assertLex(expected: List, actual: String) = - Assert.assertEquals(expected, Tokenizer(actual).tokenize()) + assertEquals(expected, Tokenizer(actual).tokenize()) fun assertLex(expected: String, actual: String) = - Assert.assertEquals(expected, Tokenizer(actual).tokenize().joinToString("")) + assertEquals(expected, Tokenizer(actual).tokenize().joinToString("")) diff --git a/data/evaluatto/src/test/java/io/github/sadellie/evaluatto/TokenizerTest.kt b/data/evaluatto/src/test/java/io/github/sadellie/evaluatto/TokenizerTest.kt index 12a23cc4..7f35b7ef 100644 --- a/data/evaluatto/src/test/java/io/github/sadellie/evaluatto/TokenizerTest.kt +++ b/data/evaluatto/src/test/java/io/github/sadellie/evaluatto/TokenizerTest.kt @@ -18,9 +18,10 @@ package io.github.sadellie.evaluatto -import org.junit.Test +import org.junit.jupiter.api.Test class TokenizerTest { + @Test fun tokens1() = assertLex(listOf("789"), "789") diff --git a/data/units/src/test/java/com/sadellie/unitto/data/units/AllUnitsRepositoryTest.kt b/data/units/src/test/java/com/sadellie/unitto/data/units/AllUnitsRepositoryTest.kt index ab6b2a74..a1bfc406 100644 --- a/data/units/src/test/java/com/sadellie/unitto/data/units/AllUnitsRepositoryTest.kt +++ b/data/units/src/test/java/com/sadellie/unitto/data/units/AllUnitsRepositoryTest.kt @@ -21,8 +21,8 @@ package com.sadellie.unitto.data.units import com.sadellie.unitto.data.model.ALL_UNIT_GROUPS import com.sadellie.unitto.data.model.AbstractUnit import com.sadellie.unitto.data.model.UnitGroup -import org.junit.Assert.assertEquals -import org.junit.Test +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test import java.math.BigDecimal class AllUnitsRepositoryTest { diff --git a/data/units/src/test/java/com/sadellie/unitto/data/units/AllUnitsTest.kt b/data/units/src/test/java/com/sadellie/unitto/data/units/AllUnitsTest.kt index 314db4d2..e5872ddb 100644 --- a/data/units/src/test/java/com/sadellie/unitto/data/units/AllUnitsTest.kt +++ b/data/units/src/test/java/com/sadellie/unitto/data/units/AllUnitsTest.kt @@ -20,14 +20,11 @@ package com.sadellie.unitto.data.units import com.sadellie.unitto.data.model.NumberBaseUnit import com.sadellie.unitto.data.model.UnitGroup -import org.junit.After -import org.junit.Assert.assertEquals -import org.junit.Test -import org.junit.runner.RunWith -import org.junit.runners.JUnit4 +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test import java.math.BigDecimal -@RunWith(JUnit4::class) class AllUnitsTest { // Group and it's tested unit ids @@ -527,7 +524,7 @@ class AllUnitsTest { history[unitFrom.group] = content.plus(this) } - @After + @AfterEach fun after() { val unitGroup = history.keys.first() // GROUP : testedCount / totalCount diff --git a/data/units/src/test/java/com/sadellie/unitto/data/units/LevenshteinFilterAndSortTest.kt b/data/units/src/test/java/com/sadellie/unitto/data/units/LevenshteinFilterAndSortTest.kt index 95d56a27..63cd6de9 100644 --- a/data/units/src/test/java/com/sadellie/unitto/data/units/LevenshteinFilterAndSortTest.kt +++ b/data/units/src/test/java/com/sadellie/unitto/data/units/LevenshteinFilterAndSortTest.kt @@ -22,8 +22,8 @@ import com.sadellie.unitto.data.model.AbstractUnit import com.sadellie.unitto.data.model.DefaultUnit import com.sadellie.unitto.data.model.UnitGroup import com.sadellie.unitto.data.model.sortByLev -import org.junit.Assert.assertEquals -import org.junit.Test +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test import java.math.BigDecimal val baseList: List = listOf( diff --git a/data/units/src/test/java/com/sadellie/unitto/data/units/LevenshteinTest.kt b/data/units/src/test/java/com/sadellie/unitto/data/units/LevenshteinTest.kt index e9d4df7f..1cdb72bf 100644 --- a/data/units/src/test/java/com/sadellie/unitto/data/units/LevenshteinTest.kt +++ b/data/units/src/test/java/com/sadellie/unitto/data/units/LevenshteinTest.kt @@ -19,8 +19,8 @@ package com.sadellie.unitto.data.units import com.sadellie.unitto.data.common.lev -import org.junit.Assert.assertEquals -import org.junit.Test +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test class LevenshteinTest { diff --git a/data/units/src/test/java/com/sadellie/unitto/data/units/MinimumRequiredScaleTest.kt b/data/units/src/test/java/com/sadellie/unitto/data/units/MinimumRequiredScaleTest.kt index 1deaf6d3..28657026 100644 --- a/data/units/src/test/java/com/sadellie/unitto/data/units/MinimumRequiredScaleTest.kt +++ b/data/units/src/test/java/com/sadellie/unitto/data/units/MinimumRequiredScaleTest.kt @@ -19,8 +19,8 @@ package com.sadellie.unitto.data.units import com.sadellie.unitto.data.common.setMinimumRequiredScale -import org.junit.Assert.assertEquals -import org.junit.Test +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test import java.math.BigDecimal class MinimumRequiredScaleTest { 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 51758964..f99fbd4b 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 @@ -47,6 +47,7 @@ import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -120,8 +121,8 @@ private fun CalculatorScreen( val dragCoroutineScope = rememberCoroutineScope() val dragAnimSpec = rememberSplineBasedDecay() - var textThingyHeight by remember { mutableStateOf(0) } - var historyItemHeight by remember { mutableStateOf(0) } + var textThingyHeight by remember { mutableIntStateOf(0) } + var historyItemHeight by remember { mutableIntStateOf(0) } var showClearHistoryDialog by rememberSaveable { mutableStateOf(false) } val showClearHistoryButton by remember(dragAmount.value, historyItemHeight) { diff --git a/feature/converter/build.gradle.kts b/feature/converter/build.gradle.kts index 91b5c875..296ff6c4 100644 --- a/feature/converter/build.gradle.kts +++ b/feature/converter/build.gradle.kts @@ -30,7 +30,6 @@ android { dependencies { testImplementation(libs.junit) testImplementation(libs.org.jetbrains.kotlinx.coroutines.test) - testImplementation(libs.org.robolectric) testImplementation(libs.androidx.room.runtime) testImplementation(libs.androidx.room.ktx) kapt(libs.androidx.room.compiler) diff --git a/feature/converter/src/test/java/com/sadellie/unitto/feature/converter/CoroutinesTestUtils.kt b/feature/converter/src/test/java/com/sadellie/unitto/feature/converter/CoroutinesTestUtils.kt deleted file mode 100644 index 68734339..00000000 --- a/feature/converter/src/test/java/com/sadellie/unitto/feature/converter/CoroutinesTestUtils.kt +++ /dev/null @@ -1,44 +0,0 @@ -/* - * Unitto is a unit converter for Android - * Copyright (c) 2022-2023 Elshan Agaev - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package com.sadellie.unitto.feature.converter - -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.TestDispatcher -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.resetMain -import kotlinx.coroutines.test.setMain -import org.junit.rules.TestWatcher -import org.junit.runner.Description - -@ExperimentalCoroutinesApi -class CoroutineTestRule(private val dispatcher: TestDispatcher = UnconfinedTestDispatcher()) : - TestWatcher() { - - override fun starting(description: Description) { - super.starting(description) - Dispatchers.setMain(dispatcher) - } - - override fun finished(description: Description) { - super.finished(description) - Dispatchers.resetMain() - } - -} diff --git a/feature/datedifference/src/test/java/com/sadellie/unitto/feature/datedifference/DateDifferenceKtTest.kt b/feature/datedifference/src/test/java/com/sadellie/unitto/feature/datedifference/DateDifferenceKtTest.kt index b7e8162b..7591a9ae 100644 --- a/feature/datedifference/src/test/java/com/sadellie/unitto/feature/datedifference/DateDifferenceKtTest.kt +++ b/feature/datedifference/src/test/java/com/sadellie/unitto/feature/datedifference/DateDifferenceKtTest.kt @@ -18,8 +18,8 @@ package com.sadellie.unitto.feature.datedifference -import org.junit.Assert.assertEquals -import org.junit.Test +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test import java.time.LocalDateTime import java.time.format.DateTimeFormatter diff --git a/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/AboutScreen.kt b/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/AboutScreen.kt index 08d4b585..0bae6585 100644 --- a/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/AboutScreen.kt +++ b/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/AboutScreen.kt @@ -37,6 +37,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue @@ -59,7 +60,7 @@ internal fun AboutScreen( ) { val mContext = LocalContext.current val userPrefs = viewModel.userPrefs.collectAsStateWithLifecycle() - var aboutItemClick: Int by rememberSaveable { mutableStateOf(0) } + var aboutItemClick: Int by rememberSaveable { mutableIntStateOf(0) } var showDialog: Boolean by rememberSaveable { mutableStateOf(false) } UnittoScreenWithLargeTopBar( diff --git a/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/formatting/FormattingScreen.kt b/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/formatting/FormattingScreen.kt index 1d4e624f..a9929b51 100644 --- a/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/formatting/FormattingScreen.kt +++ b/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/formatting/FormattingScreen.kt @@ -40,7 +40,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier @@ -56,10 +56,10 @@ import com.sadellie.unitto.core.base.OutputFormat import com.sadellie.unitto.core.base.R import com.sadellie.unitto.core.base.Separator import com.sadellie.unitto.core.ui.common.NavigateUpButton -import com.sadellie.unitto.core.ui.common.UnittoSlider import com.sadellie.unitto.core.ui.common.SegmentedButton import com.sadellie.unitto.core.ui.common.SegmentedButtonsRow import com.sadellie.unitto.core.ui.common.UnittoScreenWithLargeTopBar +import com.sadellie.unitto.core.ui.common.UnittoSlider import com.sadellie.unitto.core.ui.common.squashable import com.sadellie.unitto.core.ui.common.textfield.formatExpression import com.sadellie.unitto.core.ui.theme.NumbersTextStyleDisplayMedium @@ -255,9 +255,9 @@ fun FormattingScreen( @Preview @Composable private fun PreviewFormattingScreen() { - var currentPrecision by remember { mutableStateOf(6) } - var currentSeparator by remember { mutableStateOf(Separator.COMMA) } - var currentOutputFormat by remember { mutableStateOf(OutputFormat.PLAIN) } + var currentPrecision by remember { mutableIntStateOf(6) } + var currentSeparator by remember { mutableIntStateOf(Separator.COMMA) } + var currentOutputFormat by remember { mutableIntStateOf(OutputFormat.PLAIN) } FormattingScreen( uiState = FormattingUIState( diff --git a/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/navigation/SettingsNavigation.kt b/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/navigation/SettingsNavigation.kt index 1d14d759..96ca4201 100644 --- a/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/navigation/SettingsNavigation.kt +++ b/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/navigation/SettingsNavigation.kt @@ -21,7 +21,6 @@ package com.sadellie.unitto.feature.settings.navigation import androidx.navigation.NavController import androidx.navigation.NavGraphBuilder import androidx.navigation.NavHostController -import androidx.navigation.NavOptionsBuilder import androidx.navigation.compose.composable import androidx.navigation.compose.navigation import com.sadellie.unitto.core.base.TopLevelDestinations @@ -41,8 +40,8 @@ internal const val thirdPartyRoute = "third_party_route" internal const val aboutRoute = "about_route" internal const val formattingRoute = "formatting_route" -fun NavController.navigateToSettings(builder: NavOptionsBuilder.() -> Unit) { - navigate(settingsRoute, builder) +fun NavController.navigateToSettings() { + navigate(settingsRoute) } fun NavController.navigateToUnitGroups() { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 67e1e916..66a7bbe4 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,39 +1,39 @@ [versions] appCode = "22" appName = "Lilac Luster" -kotlin = "1.8.21" -androidxCore = "1.10.0" -androidGradlePlugin = "8.0.1" -orgJetbrainsKotlinxCoroutinesTest = "1.6.4" +kotlin = "1.9.0" +androidxCore = "1.10.1" +androidGradlePlugin = "8.0.2" +orgJetbrainsKotlinxCoroutinesTest = "1.7.2" androidxCompose = "1.5.0-alpha02" -androidxComposeCompiler = "1.4.7" -androidxComposeUi = "1.5.0-alpha04" -androidxComposeMaterial3 = "1.2.0-alpha01" -androidxNavigation = "2.5.3" +androidxComposeCompiler = "1.5.0" +androidxComposeUi = "1.6.0-alpha01" +androidxComposeMaterial3 = "1.2.0-alpha03" +androidxNavigation = "2.6.0" androidxLifecycleRuntimeCompose = "2.6.1" androidxHilt = "1.0.0" -comGoogleDagger = "2.45" -androidxComposeMaterialIconsExtended = "1.5.0-alpha04" +comGoogleDagger = "2.47" +androidxComposeMaterialIconsExtended = "1.6.0-alpha01" androidxDatastore = "1.0.0" comGoogleAccompanist = "0.30.1" -androidxRoom = "2.5.1" -comSquareupMoshi = "1.14.0" +androidxRoom = "2.6.0-alpha02" +comSquareupMoshi = "1.15.0" comSquareupRetrofit2 = "2.9.0" -comGithubSadellieThemmo = "ed4063f70f" +comGithubSadellieThemmo = "1.0.0" orgBurnoutcrewComposereorderable = "0.9.6" -junit = "4.13.2" +junit = "5.9.3" androidxTest = "1.5.0" -androidxTestExt = "1.1.4" +androidxTestExt = "1.1.5" androidDesugarJdkLibs = "2.0.3" -androidxTestRunner = "1.5.1" +androidxTestRunner = "1.5.2" androidxTestRules = "1.5.0" -orgRobolectric = "4.9" +orgRobolectric = "4.10.3" [libraries] androidx-core = { group = "androidx.core", name = "core-ktx", version.ref = "androidxCore" } androidx-test = { group = "androidx.test", name = "core", version.ref = "androidxTest" } androidx-test-ext = { group = "androidx.test.ext", name = "junit-ktx", version.ref = "androidxTestExt" } -junit = { group = "junit", name = "junit", version.ref = "junit" } +junit = { group = "org.junit.jupiter", name = "junit-jupiter-api", version.ref = "junit" } androidx-test-runner = { group = "androidx.test", name = "runner", version.ref = "androidxTestRunner" } androidx-test-rules = { group = "androidx.test", name = "rules", version.ref = "androidxTestRules" } org-robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "orgRobolectric" } From 0dbba0aa2e716609e4209fccc4f7db466dc85e75 Mon Sep 17 00:00:00 2001 From: sadellie Date: Fri, 21 Jul 2023 22:09:17 +0300 Subject: [PATCH 48/51] Close drawer on back pressed --- app/src/main/java/com/sadellie/unitto/UnittoApp.kt | 6 ++++++ .../unitto/core/ui/common/UnittoNavigationDrawer.kt | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/sadellie/unitto/UnittoApp.kt b/app/src/main/java/com/sadellie/unitto/UnittoApp.kt index 131b7a64..224f872a 100644 --- a/app/src/main/java/com/sadellie/unitto/UnittoApp.kt +++ b/app/src/main/java/com/sadellie/unitto/UnittoApp.kt @@ -18,6 +18,7 @@ package com.sadellie.unitto +import androidx.activity.compose.BackHandler import androidx.compose.animation.core.tween import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.material3.MaterialTheme @@ -40,6 +41,7 @@ import com.sadellie.unitto.core.base.TopLevelDestinations import com.sadellie.unitto.core.ui.common.UnittoDrawerSheet import com.sadellie.unitto.core.ui.common.UnittoModalNavigationDrawer import com.sadellie.unitto.core.ui.common.close +import com.sadellie.unitto.core.ui.common.isOpen import com.sadellie.unitto.core.ui.common.open import com.sadellie.unitto.core.ui.common.rememberUnittoDrawerState import com.sadellie.unitto.core.ui.model.DrawerItems @@ -133,6 +135,10 @@ internal fun UnittoApp(uiPrefs: UIPreferences) { } ) + BackHandler(drawerState.isOpen) { + drawerScope.launch { drawerState.close() } + } + LaunchedEffect(useDarkIcons) { sysUiController.setNavigationBarColor(Color.Transparent, useDarkIcons) sysUiController.setStatusBarColor(Color.Transparent, useDarkIcons) diff --git a/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/UnittoNavigationDrawer.kt b/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/UnittoNavigationDrawer.kt index 08fd55ac..64339077 100644 --- a/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/UnittoNavigationDrawer.kt +++ b/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/UnittoNavigationDrawer.kt @@ -139,7 +139,7 @@ fun rememberUnittoDrawerState( } } -private val AnchoredDraggableState.isOpen +val AnchoredDraggableState.isOpen get() = this.currentValue == UnittoDrawerState.OPEN suspend fun AnchoredDraggableState.close() { From 4affbda988d7f242a38a9e99a73210626a635741 Mon Sep 17 00:00:00 2001 From: sadellie Date: Fri, 21 Jul 2023 22:17:55 +0300 Subject: [PATCH 49/51] Calculator screen by default --- .../java/com/sadellie/unitto/data/userprefs/UserPreferences.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/data/userprefs/src/main/java/com/sadellie/unitto/data/userprefs/UserPreferences.kt b/data/userprefs/src/main/java/com/sadellie/unitto/data/userprefs/UserPreferences.kt index 9f010e1b..cee3d379 100644 --- a/data/userprefs/src/main/java/com/sadellie/unitto/data/userprefs/UserPreferences.kt +++ b/data/userprefs/src/main/java/com/sadellie/unitto/data/userprefs/UserPreferences.kt @@ -78,7 +78,7 @@ data class UserPreferences( val shownUnitGroups: List = ALL_UNIT_GROUPS, val enableVibrations: Boolean = true, val enableToolsExperiment: Boolean = false, - val startingScreen: String = TopLevelDestinations.Converter.route, + val startingScreen: String = TopLevelDestinations.Calculator.route, val radianMode: Boolean = true, val unitConverterFavoritesOnly: Boolean = false, val unitConverterFormatTime: Boolean = false, From cce8799ce5221606a5bb66719b57149bfaee956c Mon Sep 17 00:00:00 2001 From: sadellie Date: Fri, 21 Jul 2023 22:24:22 +0300 Subject: [PATCH 50/51] Thank you Google, very cool closes #22 --- .../sadellie/unitto/core/ui/common/textfield/InputTextField.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/textfield/InputTextField.kt b/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/textfield/InputTextField.kt index c91470b7..e07348c5 100644 --- a/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/textfield/InputTextField.kt +++ b/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/textfield/InputTextField.kt @@ -197,7 +197,7 @@ private fun AutoSizableTextField( ) { with(density) { // Cursor handle is not visible without this, 0.836f is the minimum required factor here - nFontSize = maxHeight.toSp() * 0.836f + nFontSize = maxHeight.toSp() * 0.835f minFontSize = nFontSize * minRatio } From ac4c6665fa87621dbdebd4e068f63865a80ec808 Mon Sep 17 00:00:00 2001 From: sadellie Date: Fri, 21 Jul 2023 22:55:38 +0300 Subject: [PATCH 51/51] Adaptive time selector. Date selector will be fixed by Google (hopefully). Users can switch to text input for convenience. closes #64 --- .../core/ui/common/DateTimePickerDialog.kt | 8 ++++++-- .../datedifference/DateDifferenceScreen.kt | 17 ++++++++++++----- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/DateTimePickerDialog.kt b/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/DateTimePickerDialog.kt index 5f038a52..3f76bd75 100644 --- a/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/DateTimePickerDialog.kt +++ b/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/DateTimePickerDialog.kt @@ -36,6 +36,7 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TimePicker +import androidx.compose.material3.TimePickerLayoutType import androidx.compose.material3.rememberDatePickerState import androidx.compose.material3.rememberTimePickerState import androidx.compose.runtime.Composable @@ -63,6 +64,7 @@ fun TimePickerDialog( dismissLabel: String = stringResource(R.string.cancel_label), onDismiss: () -> Unit = {}, onConfirm: (LocalDateTime) -> Unit, + vertical: Boolean ) { val pickerState = rememberTimePickerState( localDateTime.hour, @@ -73,7 +75,7 @@ fun TimePickerDialog( AlertDialog( onDismissRequest = onDismiss, modifier = modifier.wrapContentHeight(), - properties = DialogProperties() + properties = DialogProperties(usePlatformDefaultWidth = vertical) ) { Surface( modifier = modifier, @@ -94,7 +96,8 @@ fun TimePickerDialog( TimePicker( state = pickerState, - modifier = Modifier.padding(top = 20.dp) + modifier = Modifier.padding(top = 20.dp), + layoutType = if (vertical) TimePickerLayoutType.Vertical else TimePickerLayoutType.Horizontal ) Row( @@ -153,6 +156,7 @@ fun DatePickerDialog( Box(modifier = Modifier .align(Alignment.End) .padding(DialogButtonsPadding)) { + AlertDialogFlowRow( mainAxisSpacing = DialogButtonsMainAxisSpacing, crossAxisSpacing = DialogButtonsCrossAxisSpacing diff --git a/feature/datedifference/src/main/java/com/sadellie/unitto/feature/datedifference/DateDifferenceScreen.kt b/feature/datedifference/src/main/java/com/sadellie/unitto/feature/datedifference/DateDifferenceScreen.kt index fdc56887..f4cd5377 100644 --- a/feature/datedifference/src/main/java/com/sadellie/unitto/feature/datedifference/DateDifferenceScreen.kt +++ b/feature/datedifference/src/main/java/com/sadellie/unitto/feature/datedifference/DateDifferenceScreen.kt @@ -18,6 +18,7 @@ package com.sadellie.unitto.feature.datedifference +import android.content.res.Configuration import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.expandVertically import androidx.compose.animation.shrinkVertically @@ -33,6 +34,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -72,9 +74,10 @@ internal fun DateDifferenceScreen( navigateToSettings: () -> Unit, updateStart: (LocalDateTime) -> Unit, updateEnd: (LocalDateTime) -> Unit, - uiState: UIState + uiState: UIState, ) { var dialogState by remember { mutableStateOf(DialogState.NONE) } + val isVertical = LocalConfiguration.current.orientation == Configuration.ORIENTATION_PORTRAIT UnittoScreenWithTopBar( title = { Text(stringResource(R.string.date_difference)) }, @@ -141,7 +144,8 @@ internal fun DateDifferenceScreen( updateStart(it) dialogState = DialogState.FROM_DATE }, - confirmLabel = stringResource(R.string.next_label) + confirmLabel = stringResource(R.string.next_label), + vertical = isVertical, ) } @@ -152,7 +156,8 @@ internal fun DateDifferenceScreen( onConfirm = { updateStart(it) resetDialog() - } + }, + vertical = isVertical, ) } @@ -175,7 +180,8 @@ internal fun DateDifferenceScreen( updateEnd(it) dialogState = DialogState.TO_DATE }, - confirmLabel = stringResource(R.string.next_label) + confirmLabel = stringResource(R.string.next_label), + vertical = isVertical, ) } @@ -186,7 +192,8 @@ internal fun DateDifferenceScreen( onConfirm = { updateEnd(it) resetDialog() - } + }, + vertical = isVertical, ) }