├── .idea ├── .name ├── .gitignore ├── vcs.xml ├── compiler.xml ├── kotlinc.xml ├── migrations.xml ├── deploymentTargetDropDown.xml ├── artifacts │ └── composeApp_desktop.xml ├── gradle.xml ├── appInsightsSettings.xml ├── inspectionProfiles │ └── Project_Default.xml └── misc.xml ├── composeApp ├── src │ ├── androidMain │ │ ├── res │ │ │ ├── values │ │ │ │ ├── strings.xml │ │ │ │ ├── themes.xml │ │ │ │ ├── ic_launcher_background.xml │ │ │ │ └── colors.xml │ │ │ ├── mipmap-hdpi │ │ │ │ ├── ic_launcher.png │ │ │ │ ├── ic_launcher_round.png │ │ │ │ └── ic_launcher_foreground.png │ │ │ ├── mipmap-mdpi │ │ │ │ ├── ic_launcher.png │ │ │ │ ├── ic_launcher_round.png │ │ │ │ └── ic_launcher_foreground.png │ │ │ ├── mipmap-xhdpi │ │ │ │ ├── ic_launcher.png │ │ │ │ ├── ic_launcher_round.png │ │ │ │ └── ic_launcher_foreground.png │ │ │ ├── mipmap-xxhdpi │ │ │ │ ├── ic_launcher.png │ │ │ │ ├── ic_launcher_round.png │ │ │ │ └── ic_launcher_foreground.png │ │ │ ├── mipmap-xxxhdpi │ │ │ │ ├── ic_launcher.png │ │ │ │ ├── ic_launcher_round.png │ │ │ │ └── ic_launcher_foreground.png │ │ │ ├── mipmap-anydpi-v26 │ │ │ │ ├── ic_launcher.xml │ │ │ │ └── ic_launcher_round.xml │ │ │ └── xml │ │ │ │ ├── backup_rules.xml │ │ │ │ └── data_extraction_rules.xml │ │ ├── ic_launcher-playstore.png │ │ ├── kotlin │ │ │ ├── di │ │ │ │ └── DatabaseModule.android.kt │ │ │ ├── presentation │ │ │ │ └── util │ │ │ │ │ └── text │ │ │ │ │ └── ComposeTextUtils.android.kt │ │ │ ├── me │ │ │ │ └── joaomanaia │ │ │ │ │ └── calculator │ │ │ │ │ └── compose │ │ │ │ │ ├── CalculatorApp.kt │ │ │ │ │ └── MainActivity.kt │ │ │ ├── previews │ │ │ │ ├── components │ │ │ │ │ ├── button │ │ │ │ │ │ ├── secondary │ │ │ │ │ │ │ ├── SecondaryButtonComponentPreview.kt │ │ │ │ │ │ │ └── SecondaryButtonGridPreview.kt │ │ │ │ │ │ └── primary │ │ │ │ │ │ │ └── ButtonComponentPreview.kt │ │ │ │ │ └── expression │ │ │ │ │ │ └── ExpressionContentPreview.kt │ │ │ │ └── home │ │ │ │ │ └── HomeScreenPreview.kt │ │ │ └── core │ │ │ │ └── presentation │ │ │ │ └── theme │ │ │ │ └── Theme.android.kt │ │ └── AndroidManifest.xml │ ├── commonMain │ │ └── kotlin │ │ │ ├── di │ │ │ ├── DatabaseModule.kt │ │ │ ├── KoinStarter.kt │ │ │ └── EvaluatorModule.kt │ │ │ ├── core │ │ │ ├── evaluator │ │ │ │ ├── internal │ │ │ │ │ ├── ExpressionException.kt │ │ │ │ │ ├── Function.kt │ │ │ │ │ ├── Token.kt │ │ │ │ │ ├── TokenType.kt │ │ │ │ │ ├── Expr.kt │ │ │ │ │ ├── Scanner.kt │ │ │ │ │ ├── Evaluator.kt │ │ │ │ │ └── Parser.kt │ │ │ │ └── Expressions.kt │ │ │ ├── presentation │ │ │ │ └── theme │ │ │ │ │ ├── Type.kt │ │ │ │ │ ├── Theme.kt │ │ │ │ │ └── Spacing.kt │ │ │ ├── util │ │ │ │ ├── ExpressionUtil.kt │ │ │ │ └── ExpressionUtilImpl.kt │ │ │ └── ButtonAction.kt │ │ │ ├── model │ │ │ └── AngleType.kt │ │ │ ├── domain │ │ │ ├── result │ │ │ │ ├── ExpressionResult.kt │ │ │ │ └── ExpressionResultDataSource.kt │ │ │ └── time │ │ │ │ └── DateTimeUtil.kt │ │ │ ├── presentation │ │ │ ├── util │ │ │ │ └── text │ │ │ │ │ └── ComposeTextUtils.kt │ │ │ ├── home │ │ │ │ ├── HomeUiState.kt │ │ │ │ ├── HomeUiEvent.kt │ │ │ │ ├── components │ │ │ │ │ └── HistoryComponent.kt │ │ │ │ ├── HomeViewModel.kt │ │ │ │ └── HomeScreen.kt │ │ │ └── components │ │ │ │ ├── button │ │ │ │ ├── secondary │ │ │ │ │ ├── SecondaryButtonComponent.kt │ │ │ │ │ └── SecondaryGrid.kt │ │ │ │ └── primary │ │ │ │ │ ├── ButtonGrid.kt │ │ │ │ │ └── ButtonComponent.kt │ │ │ │ ├── text │ │ │ │ └── AutoSizeText.kt │ │ │ │ └── expression │ │ │ │ └── ExpressionContent.kt │ │ │ └── App.kt │ ├── desktopMain │ │ └── kotlin │ │ │ ├── di │ │ │ └── DatabaseModule.desktop.kt │ │ │ ├── presentation │ │ │ └── util │ │ │ │ └── text │ │ │ │ └── ComposeTextUtils.desktop.kt │ │ │ ├── core │ │ │ └── presentation │ │ │ │ └── theme │ │ │ │ └── Theme.desktop.kt │ │ │ └── main.kt │ └── commonTest │ │ └── kotlin │ │ └── core │ │ └── util │ │ └── ExpressionUtilImplTest.kt └── build.gradle.kts ├── gradle ├── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties └── libs.versions.toml ├── pictures └── calculator_screenshot.jpg ├── lint.xml ├── settings.gradle.kts ├── README.md ├── .gitignore ├── gradle.properties ├── gradlew.bat ├── .github └── workflows │ └── android-ci.yml └── gradlew /.idea/.name: -------------------------------------------------------------------------------- 1 | Calculator -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | Calculator 3 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joaomanaia/calculator-compose/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /pictures/calculator_screenshot.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joaomanaia/calculator-compose/HEAD/pictures/calculator_screenshot.jpg -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | # GitHub Copilot persisted chat sessions 5 | /copilot/chatSessions 6 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/di/DatabaseModule.kt: -------------------------------------------------------------------------------- 1 | package di 2 | 3 | import org.koin.core.module.Module 4 | 5 | expect val databaseModule: Module -------------------------------------------------------------------------------- /composeApp/src/androidMain/ic_launcher-playstore.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joaomanaia/calculator-compose/HEAD/composeApp/src/androidMain/ic_launcher-playstore.png -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joaomanaia/calculator-compose/HEAD/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joaomanaia/calculator-compose/HEAD/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joaomanaia/calculator-compose/HEAD/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/values/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joaomanaia/calculator-compose/HEAD/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joaomanaia/calculator-compose/HEAD/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joaomanaia/calculator-compose/HEAD/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joaomanaia/calculator-compose/HEAD/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joaomanaia/calculator-compose/HEAD/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joaomanaia/calculator-compose/HEAD/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/values/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #3584E4 4 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/core/evaluator/internal/ExpressionException.kt: -------------------------------------------------------------------------------- 1 | package core.evaluator.internal 2 | 3 | class ExpressionException(message: String) : RuntimeException(message) 4 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/core/presentation/theme/Type.kt: -------------------------------------------------------------------------------- 1 | package core.presentation.theme 2 | 3 | import androidx.compose.material3.Typography 4 | 5 | val AppTypography = Typography() 6 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joaomanaia/calculator-compose/HEAD/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joaomanaia/calculator-compose/HEAD/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joaomanaia/calculator-compose/HEAD/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /.idea/compiler.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joaomanaia/calculator-compose/HEAD/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joaomanaia/calculator-compose/HEAD/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /.idea/kotlinc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joaomanaia/calculator-compose/HEAD/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /composeApp/src/androidMain/kotlin/di/DatabaseModule.android.kt: -------------------------------------------------------------------------------- 1 | package di 2 | 3 | import org.koin.core.module.Module 4 | import org.koin.dsl.bind 5 | import org.koin.dsl.module 6 | 7 | actual val databaseModule: Module = module {} 8 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/core/evaluator/internal/Function.kt: -------------------------------------------------------------------------------- 1 | package core.evaluator.internal 2 | 3 | import java.math.BigDecimal 4 | 5 | abstract class Function { 6 | abstract fun call(arguments: List): BigDecimal 7 | } 8 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/model/AngleType.kt: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | enum class AngleType { 4 | DEG, RAD; 5 | 6 | fun next(): AngleType { 7 | return when (this) { 8 | DEG -> RAD 9 | RAD -> DEG 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /composeApp/src/desktopMain/kotlin/di/DatabaseModule.desktop.kt: -------------------------------------------------------------------------------- 1 | package di 2 | 3 | import domain.result.ExpressionResultDataSource 4 | import org.koin.core.module.Module 5 | import org.koin.dsl.bind 6 | import org.koin.dsl.module 7 | 8 | actual val databaseModule: Module = module {} 9 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/domain/result/ExpressionResult.kt: -------------------------------------------------------------------------------- 1 | package domain.result 2 | 3 | import kotlinx.datetime.LocalDateTime 4 | 5 | data class ExpressionResult( 6 | val expression: String, 7 | val result: String, 8 | val createdAt: LocalDateTime 9 | ) 10 | -------------------------------------------------------------------------------- /lint.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/presentation/util/text/ComposeTextUtils.kt: -------------------------------------------------------------------------------- 1 | package presentation.util.text 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.ui.text.font.FontFamily 5 | 6 | @Composable 7 | expect fun createFontFamilyResolver(): FontFamily.Resolver 8 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Sun Apr 03 12:48:30 WEST 2022 2 | distributionBase=GRADLE_USER_HOME 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip 4 | distributionPath=wrapper/dists 5 | zipStorePath=wrapper/dists 6 | zipStoreBase=GRADLE_USER_HOME 7 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/core/evaluator/internal/Token.kt: -------------------------------------------------------------------------------- 1 | package core.evaluator.internal 2 | 3 | internal class Token( 4 | val type: TokenType, 5 | val lexeme: String, 6 | val literal: Any?, 7 | ) { 8 | override fun toString() = "$type $lexeme $literal" 9 | } 10 | -------------------------------------------------------------------------------- /.idea/migrations.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 10 | -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /.idea/deploymentTargetDropDown.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /composeApp/src/desktopMain/kotlin/presentation/util/text/ComposeTextUtils.desktop.kt: -------------------------------------------------------------------------------- 1 | package presentation.util.text 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.ui.text.font.FontFamily 5 | 6 | @Composable 7 | actual fun createFontFamilyResolver(): FontFamily.Resolver { 8 | return androidx.compose.ui.text.font.createFontFamilyResolver() 9 | } -------------------------------------------------------------------------------- /.idea/artifacts/composeApp_desktop.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | $PROJECT_DIR$/composeApp/build/libs 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/domain/result/ExpressionResultDataSource.kt: -------------------------------------------------------------------------------- 1 | package domain.result 2 | 3 | import kotlinx.coroutines.flow.Flow 4 | 5 | interface ExpressionResultDataSource { 6 | suspend fun insertResult(result: ExpressionResult) 7 | 8 | fun getAllResultsFlow(): Flow> 9 | 10 | suspend fun deleteResult(id: Long) 11 | } 12 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | pluginManagement { 2 | repositories { 3 | gradlePluginPortal() 4 | google() 5 | mavenCentral() 6 | } 7 | } 8 | dependencyResolutionManagement { 9 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) 10 | repositories { 11 | google() 12 | mavenCentral() 13 | } 14 | } 15 | 16 | enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") 17 | 18 | rootProject.name = "Calculator" 19 | include(":composeApp") -------------------------------------------------------------------------------- /composeApp/src/androidMain/kotlin/presentation/util/text/ComposeTextUtils.android.kt: -------------------------------------------------------------------------------- 1 | package presentation.util.text 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.ui.platform.LocalContext 5 | import androidx.compose.ui.text.font.FontFamily 6 | 7 | @Composable 8 | actual fun createFontFamilyResolver(): FontFamily.Resolver { 9 | return androidx.compose.ui.text.font.createFontFamilyResolver( 10 | context = LocalContext.current 11 | ) 12 | } -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/di/KoinStarter.kt: -------------------------------------------------------------------------------- 1 | package di 2 | 3 | import io.github.oshai.kotlinlogging.KotlinLogging 4 | import org.koin.core.context.startKoin 5 | import org.koin.dsl.KoinAppDeclaration 6 | 7 | object KoinStarter { 8 | private val logger = KotlinLogging.logger("KoinStarter") 9 | 10 | fun init(config: KoinAppDeclaration? = null) = startKoin { 11 | logger.trace { "Starting Koin" } 12 | config?.invoke(this) 13 | modules(evaluatorModule, databaseModule) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/core/presentation/theme/Theme.kt: -------------------------------------------------------------------------------- 1 | package core.presentation.theme 2 | 3 | import androidx.compose.foundation.isSystemInDarkTheme 4 | import androidx.compose.material3.* 5 | import androidx.compose.runtime.Composable 6 | 7 | val LightThemeColors = lightColorScheme() 8 | 9 | val DarkThemeColors = darkColorScheme() 10 | 11 | @Composable 12 | expect fun CalculatorTheme( 13 | darkTheme: Boolean = isSystemInDarkTheme(), 14 | dynamicColor: Boolean = true, 15 | content: @Composable () -> Unit 16 | ) 17 | -------------------------------------------------------------------------------- /composeApp/src/androidMain/kotlin/me/joaomanaia/calculator/compose/CalculatorApp.kt: -------------------------------------------------------------------------------- 1 | package me.joaomanaia.calculator.compose 2 | 3 | import android.app.Application 4 | import di.KoinStarter 5 | import org.koin.android.ext.koin.androidContext 6 | import org.koin.android.ext.koin.androidLogger 7 | 8 | class CalculatorApp : Application() { 9 | override fun onCreate() { 10 | super.onCreate() 11 | 12 | KoinStarter.init { 13 | androidLogger() 14 | androidContext(this@CalculatorApp) 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/xml/backup_rules.xml: -------------------------------------------------------------------------------- 1 | 8 | 9 | 13 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/presentation/home/HomeUiState.kt: -------------------------------------------------------------------------------- 1 | package presentation.home 2 | 3 | import androidx.compose.ui.text.input.TextFieldValue 4 | import model.AngleType 5 | import core.evaluator.Expressions 6 | import domain.result.ExpressionResult 7 | 8 | data class HomeUiState( 9 | val currentExpression: TextFieldValue = TextFieldValue(text = ""), 10 | val moreActionsExpanded: Boolean = false, 11 | val angleType: AngleType = Expressions.DEFAULT_ANGLE_TYPE, 12 | val isInverse: Boolean = false, 13 | val results: List = emptyList(), 14 | ) 15 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/presentation/home/HomeUiEvent.kt: -------------------------------------------------------------------------------- 1 | package presentation.home 2 | 3 | import androidx.compose.ui.text.input.TextFieldValue 4 | import core.ButtonAction 5 | 6 | sealed interface HomeUiEvent { 7 | data class OnButtonActionClick( 8 | val action: ButtonAction 9 | ) : HomeUiEvent 10 | 11 | data class UpdateTextFieldValue( 12 | val value: TextFieldValue 13 | ) : HomeUiEvent 14 | 15 | data class InsertIntoExpression( 16 | val value: String 17 | ) : HomeUiEvent 18 | 19 | data object OnChangeMoreActionsClick : HomeUiEvent 20 | } 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Jetpack compose calculator 2 | 3 | [![Android CI](https://github.com/joaomanaia/calculator-compose/actions/workflows/android-ci.yml/badge.svg?branch=main)](https://github.com/joaomanaia/calculator-compose/actions/workflows/android-ci.yml) 4 | 5 | A jetpack compose calculator clone of google calculator written with 100% Jetpack Compose. 6 | 7 | This calculator uses an implementation of the [ExprK](https://github.com/Keelar/ExprK) library to calculate expressions. 8 | 9 | Calculator Screenshot 10 | 11 | ## Features 12 | - Jetpack Compose 13 | - Material 3 14 | - MVVM 15 | -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/xml/data_extraction_rules.xml: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | 12 | 13 | 19 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/core/evaluator/internal/TokenType.kt: -------------------------------------------------------------------------------- 1 | package core.evaluator.internal 2 | 3 | internal enum class TokenType { 4 | // Basic operators 5 | PLUS, 6 | MINUS, 7 | STAR, 8 | SLASH, 9 | MODULO, 10 | EXPONENT, 11 | ASSIGN, 12 | FACTORIAL, 13 | 14 | // Logical operators 15 | EQUAL_EQUAL, 16 | NOT_EQUAL, 17 | GREATER, 18 | GREATER_EQUAL, 19 | LESS, 20 | LESS_EQUAL, 21 | BAR_BAR, 22 | AMP_AMP, 23 | 24 | // Other 25 | COMMA, 26 | 27 | // Parentheses 28 | LEFT_PAREN, 29 | RIGHT_PAREN, 30 | 31 | // Literals 32 | NUMBER, 33 | IDENTIFIER, 34 | 35 | EOF 36 | } 37 | -------------------------------------------------------------------------------- /composeApp/src/desktopMain/kotlin/core/presentation/theme/Theme.desktop.kt: -------------------------------------------------------------------------------- 1 | package core.presentation.theme 2 | 3 | import androidx.compose.material3.MaterialTheme 4 | import androidx.compose.runtime.Composable 5 | import androidx.compose.runtime.CompositionLocalProvider 6 | 7 | @Composable 8 | actual fun CalculatorTheme( 9 | darkTheme: Boolean, 10 | dynamicColor: Boolean, 11 | content: @Composable () -> Unit 12 | ) { 13 | val colorScheme = if (darkTheme) DarkThemeColors else LightThemeColors 14 | 15 | CompositionLocalProvider( 16 | LocalSpacing provides Spacing() 17 | ) { 18 | MaterialTheme( 19 | colorScheme = colorScheme, 20 | content = content 21 | ) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/core/util/ExpressionUtil.kt: -------------------------------------------------------------------------------- 1 | package core.util 2 | 3 | import androidx.compose.ui.text.input.TextFieldValue 4 | import core.ButtonAction 5 | 6 | interface ExpressionUtil { 7 | fun addParentheses(currentExpression: TextFieldValue): TextFieldValue 8 | 9 | fun calculateExpression(expression: String): String 10 | 11 | fun removeDigit(expression: TextFieldValue): TextFieldValue 12 | 13 | fun addActionValueToExpression( 14 | action: ButtonAction, 15 | currentExpression: TextFieldValue 16 | ): TextFieldValue 17 | 18 | fun addValueToExpression( 19 | value: String, 20 | currentExpression: TextFieldValue 21 | ): TextFieldValue 22 | 23 | fun changeAngleMode(newMode: model.AngleType) 24 | } 25 | -------------------------------------------------------------------------------- /.idea/gradle.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 18 | 19 | -------------------------------------------------------------------------------- /composeApp/src/androidMain/kotlin/me/joaomanaia/calculator/compose/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package me.joaomanaia.calculator.compose 2 | 3 | import App 4 | import android.os.Bundle 5 | import androidx.activity.ComponentActivity 6 | import androidx.activity.compose.setContent 7 | import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi 8 | import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass 9 | 10 | class MainActivity : ComponentActivity() { 11 | @OptIn(ExperimentalMaterial3WindowSizeClassApi::class) 12 | override fun onCreate(savedInstanceState: Bundle?) { 13 | super.onCreate(savedInstanceState) 14 | 15 | setContent { 16 | App( 17 | windowSizeClass = calculateWindowSizeClass() 18 | ) 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /composeApp/src/desktopMain/kotlin/main.kt: -------------------------------------------------------------------------------- 1 | import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi 2 | import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass 3 | import androidx.compose.ui.window.Window 4 | import androidx.compose.ui.window.application 5 | import di.KoinStarter 6 | import org.koin.logger.SLF4JLogger 7 | 8 | @OptIn(ExperimentalMaterial3WindowSizeClassApi::class) 9 | fun main() = application { 10 | System.setProperty(org.slf4j.simple.SimpleLogger.DEFAULT_LOG_LEVEL_KEY, "TRACE") 11 | 12 | KoinStarter.init { 13 | logger(SLF4JLogger()) 14 | } 15 | 16 | Window( 17 | onCloseRequest = ::exitApplication, 18 | title = "Calculator" 19 | ) { 20 | App( 21 | windowSizeClass = calculateWindowSizeClass() 22 | ) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/di/EvaluatorModule.kt: -------------------------------------------------------------------------------- 1 | package di 2 | 3 | import core.evaluator.internal.Evaluator 4 | import core.evaluator.Expressions 5 | import core.util.ExpressionUtil 6 | import core.util.ExpressionUtilImpl 7 | import org.koin.compose.viewmodel.dsl.viewModelOf 8 | import org.koin.core.module.dsl.bind 9 | import org.koin.core.module.dsl.createdAtStart 10 | import org.koin.core.module.dsl.singleOf 11 | import org.koin.dsl.module 12 | import presentation.home.HomeViewModel 13 | 14 | val evaluatorModule = module { 15 | singleOf(::Evaluator) { 16 | createdAtStart() 17 | } 18 | 19 | singleOf(::Expressions) { 20 | createdAtStart() 21 | } 22 | 23 | singleOf(::ExpressionUtilImpl) { 24 | bind() 25 | createdAtStart() 26 | } 27 | 28 | viewModelOf(::HomeViewModel) 29 | } 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | /.idea/caches 5 | /.idea/libraries 6 | /.idea/modules.xml 7 | /.idea/workspace.xml 8 | /.idea/navEditor.xml 9 | /.idea/assetWizardSettings.xml 10 | .DS_Store 11 | /build 12 | /captures 13 | .externalNativeBuild 14 | .cxx 15 | local.properties 16 | /app/release 17 | 18 | .kotlin 19 | 20 | **/build/ 21 | xcuserdata 22 | !src/**/build/ 23 | .idea 24 | captures 25 | *.xcodeproj/* 26 | !*.xcodeproj/project.pbxproj 27 | !*.xcodeproj/xcshareddata/ 28 | !*.xcodeproj/project.xcworkspace/ 29 | !*.xcworkspace/contents.xcworkspacedata 30 | **/xcshareddata/WorkspaceSettings.xcsettings 31 | 32 | .idea/* 33 | !.idea/copyright 34 | # Keep the code styles. 35 | !/.idea/codeStyles 36 | /.idea/codeStyles/* 37 | !/.idea/codeStyles/Project.xml 38 | !/.idea/codeStyles/codeStyleConfig.xml 39 | !.idea/runConfigurations/ 40 | 41 | kotlin-js-store 42 | 43 | *.preferences_pb 44 | composeApp/*.db -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/App.kt: -------------------------------------------------------------------------------- 1 | import androidx.compose.foundation.layout.fillMaxSize 2 | import androidx.compose.material3.MaterialTheme 3 | import androidx.compose.material3.Surface 4 | import androidx.compose.material3.windowsizeclass.WindowSizeClass 5 | import androidx.compose.runtime.Composable 6 | import androidx.compose.ui.Modifier 7 | import core.presentation.theme.CalculatorTheme 8 | import org.koin.compose.KoinContext 9 | import presentation.home.HomeScreen 10 | 11 | @Composable 12 | fun App( 13 | windowSizeClass: WindowSizeClass 14 | ) { 15 | CalculatorTheme { 16 | Surface( 17 | modifier = Modifier.fillMaxSize(), 18 | color = MaterialTheme.colorScheme.background 19 | ) { 20 | KoinContext { 21 | HomeScreen( 22 | windowSizeClass = windowSizeClass 23 | ) 24 | } 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/domain/time/DateTimeUtil.kt: -------------------------------------------------------------------------------- 1 | package domain.time 2 | 3 | import kotlinx.datetime.* 4 | import kotlinx.datetime.format.* 5 | 6 | object DateTimeUtil { 7 | fun now(): LocalDateTime { 8 | return Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()) 9 | } 10 | 11 | fun toEpochMilli(dateTime: LocalDateTime): Long { 12 | return dateTime 13 | .toInstant(TimeZone.currentSystemDefault()) 14 | .toEpochMilliseconds() 15 | } 16 | 17 | fun fromEpochMilli(epochMilli: Long): LocalDateTime { 18 | return Instant 19 | .fromEpochMilliseconds(epochMilli) 20 | .toLocalDateTime(TimeZone.currentSystemDefault()) 21 | } 22 | 23 | fun formatDate(dateTime: LocalDateTime): String { 24 | return LocalDateTime.Format { 25 | dayOfMonth() 26 | chars(" of ") 27 | monthName(MonthNames.ENGLISH_FULL) 28 | }.format(dateTime) 29 | } 30 | } -------------------------------------------------------------------------------- /.idea/appInsightsSettings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 25 | 26 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/core/presentation/theme/Spacing.kt: -------------------------------------------------------------------------------- 1 | package core.presentation.theme 2 | 3 | import androidx.compose.material3.MaterialTheme 4 | import androidx.compose.runtime.Composable 5 | import androidx.compose.runtime.Immutable 6 | import androidx.compose.runtime.ReadOnlyComposable 7 | import androidx.compose.runtime.staticCompositionLocalOf 8 | import androidx.compose.ui.unit.Dp 9 | import androidx.compose.ui.unit.dp 10 | 11 | @Immutable 12 | data class Spacing( 13 | /** 8.dp **/ 14 | val default: Dp = 8.dp, 15 | /** 2.dp **/ 16 | val tiny: Dp = 2.dp, 17 | /** 4.dp **/ 18 | val extraSmall: Dp = 4.dp, 19 | /** 8.dp **/ 20 | val small: Dp = 8.dp, 21 | /** 16.dp **/ 22 | val medium: Dp = 16.dp, 23 | /** 32.dp **/ 24 | val large: Dp = 32.dp, 25 | /** 64.dp **/ 26 | val extraLarge: Dp = 64.dp, 27 | ) 28 | 29 | val LocalSpacing = staticCompositionLocalOf { Spacing() } 30 | 31 | val MaterialTheme.spacing: Spacing 32 | @Composable 33 | @ReadOnlyComposable 34 | get() = LocalSpacing.current 35 | -------------------------------------------------------------------------------- /composeApp/src/androidMain/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 16 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /composeApp/src/androidMain/kotlin/previews/components/button/secondary/SecondaryButtonComponentPreview.kt: -------------------------------------------------------------------------------- 1 | package previews.components.button.secondary 2 | 3 | import androidx.compose.foundation.layout.padding 4 | import androidx.compose.foundation.layout.size 5 | import androidx.compose.material3.MaterialTheme 6 | import androidx.compose.material3.Surface 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.ui.Modifier 9 | import androidx.compose.ui.tooling.preview.PreviewLightDark 10 | import androidx.compose.ui.unit.dp 11 | import core.ButtonAction 12 | import core.presentation.theme.CalculatorTheme 13 | import core.presentation.theme.spacing 14 | import presentation.components.button.secondary.SecondaryButtonComponent 15 | 16 | @Composable 17 | @PreviewLightDark 18 | private fun SecondaryButtonComponentPreview() { 19 | CalculatorTheme { 20 | Surface { 21 | SecondaryButtonComponent( 22 | modifier = Modifier 23 | .size(100.dp) 24 | .padding(MaterialTheme.spacing.medium), 25 | buttonAction = ButtonAction.ButtonPI, 26 | onClick = {} 27 | ) 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /composeApp/src/androidMain/kotlin/previews/components/expression/ExpressionContentPreview.kt: -------------------------------------------------------------------------------- 1 | package previews.components.expression 2 | 3 | import androidx.compose.foundation.layout.fillMaxWidth 4 | import androidx.compose.foundation.layout.height 5 | import androidx.compose.foundation.layout.padding 6 | import androidx.compose.material3.MaterialTheme 7 | import androidx.compose.material3.Surface 8 | import androidx.compose.runtime.Composable 9 | import androidx.compose.ui.Modifier 10 | import androidx.compose.ui.text.input.TextFieldValue 11 | import androidx.compose.ui.tooling.preview.PreviewLightDark 12 | import androidx.compose.ui.unit.dp 13 | import core.presentation.theme.CalculatorTheme 14 | import core.presentation.theme.spacing 15 | import model.AngleType 16 | import presentation.components.expression.ExpressionContent 17 | 18 | @Composable 19 | @PreviewLightDark 20 | private fun ExpressionContentPreview() { 21 | CalculatorTheme { 22 | Surface { 23 | ExpressionContent( 24 | modifier = Modifier 25 | .padding(MaterialTheme.spacing.medium) 26 | .fillMaxWidth() 27 | .height(300.dp), 28 | currentExpression = TextFieldValue("21+3*"), 29 | result = "23", 30 | angleType = AngleType.DEG, 31 | updateTextFieldValue = {} 32 | ) 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | # Project-wide Gradle settings. 2 | # IDE (e.g. Android Studio) users: 3 | # Gradle settings configured through the IDE *will override* 4 | # any settings specified in this file. 5 | # For more details on how to configure your build environment visit 6 | # http://www.gradle.org/docs/current/userguide/build_environment.html 7 | # Specifies the JVM arguments used for the daemon process. 8 | # The setting is particularly useful for tweaking memory settings. 9 | org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 10 | # When configured, Gradle will run in incubating parallel mode. 11 | # This option should only be used with decoupled projects. More details, visit 12 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects 13 | # org.gradle.parallel=true 14 | # AndroidX package structure to make it clearer which packages are bundled with the 15 | # Android operating system, and which are packaged with your app"s APK 16 | # https://developer.android.com/topic/libraries/support-library/androidx-rn 17 | android.useAndroidX=true 18 | # Kotlin code style for this project: "official" or "obsolete": 19 | kotlin.code.style=official 20 | # Enables namespacing of each library's R class so that its R class includes only the 21 | # resources declared in the library itself and none from the library's dependencies, 22 | # thereby reducing the size of the R class for that library 23 | android.nonTransitiveRClass=true 24 | android.nonFinalResIds=false 25 | 26 | kotlin.mpp.androidGradlePluginCompatibility.nowarn=true 27 | -------------------------------------------------------------------------------- /composeApp/src/androidMain/kotlin/previews/home/HomeScreenPreview.kt: -------------------------------------------------------------------------------- 1 | package previews.home 2 | 3 | import androidx.compose.material3.Surface 4 | import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi 5 | import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass 6 | import androidx.compose.runtime.* 7 | import androidx.compose.ui.text.input.TextFieldValue 8 | import androidx.compose.ui.tooling.preview.PreviewLightDark 9 | import core.presentation.theme.CalculatorTheme 10 | import presentation.home.HomeScreenImpl 11 | import presentation.home.HomeUiEvent 12 | import presentation.home.HomeUiState 13 | 14 | @OptIn(ExperimentalMaterial3WindowSizeClassApi::class) 15 | @Composable 16 | @PreviewLightDark 17 | private fun HomeScreenPreview() { 18 | var buttonGridExpanded by remember { mutableStateOf(false) } 19 | 20 | CalculatorTheme { 21 | Surface { 22 | HomeScreenImpl( 23 | uiState = HomeUiState( 24 | currentExpression = TextFieldValue("22+1"), 25 | moreActionsExpanded = buttonGridExpanded 26 | ), 27 | result = "23", 28 | windowSizeClass = calculateWindowSizeClass(), 29 | onEvent = { event -> 30 | when (event) { 31 | is HomeUiEvent.OnChangeMoreActionsClick -> { 32 | buttonGridExpanded = !buttonGridExpanded 33 | } 34 | 35 | else -> {} 36 | } 37 | } 38 | ) 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /composeApp/src/androidMain/kotlin/previews/components/button/primary/ButtonComponentPreview.kt: -------------------------------------------------------------------------------- 1 | package previews.components.button.primary 2 | 3 | import androidx.compose.foundation.layout.* 4 | import androidx.compose.material3.MaterialTheme 5 | import androidx.compose.material3.Surface 6 | import androidx.compose.runtime.Composable 7 | import androidx.compose.ui.Modifier 8 | import androidx.compose.ui.tooling.preview.PreviewLightDark 9 | import androidx.compose.ui.unit.dp 10 | import core.ButtonAction 11 | import core.presentation.theme.CalculatorTheme 12 | import core.presentation.theme.spacing 13 | import presentation.components.button.primary.ButtonComponent 14 | 15 | @Composable 16 | @PreviewLightDark 17 | private fun SquareButtonComponentPreview() { 18 | CalculatorTheme { 19 | Surface { 20 | Column { 21 | ButtonComponent( 22 | modifier = Modifier 23 | .size(100.dp) 24 | .padding(MaterialTheme.spacing.medium), 25 | buttonAction = ButtonAction.Button1, 26 | onClick = {} 27 | ) 28 | } 29 | } 30 | } 31 | } 32 | 33 | @Composable 34 | @PreviewLightDark 35 | private fun SmallButtonComponent2Preview() { 36 | CalculatorTheme { 37 | Surface { 38 | Column { 39 | ButtonComponent( 40 | modifier = Modifier 41 | .width(100.dp) 42 | .aspectRatio(1f / 0.7f) 43 | .padding(MaterialTheme.spacing.medium), 44 | buttonAction = ButtonAction.Button1, 45 | onClick = {} 46 | ) 47 | } 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /composeApp/src/androidMain/kotlin/core/presentation/theme/Theme.android.kt: -------------------------------------------------------------------------------- 1 | package core.presentation.theme 2 | 3 | import android.app.Activity 4 | import android.os.Build 5 | import androidx.compose.material3.MaterialTheme 6 | import androidx.compose.material3.dynamicDarkColorScheme 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.runtime.CompositionLocalProvider 9 | import androidx.compose.runtime.SideEffect 10 | import androidx.compose.ui.graphics.toArgb 11 | import androidx.compose.ui.platform.LocalContext 12 | import androidx.compose.ui.platform.LocalView 13 | import androidx.core.view.WindowCompat 14 | 15 | @Composable 16 | actual fun CalculatorTheme( 17 | darkTheme: Boolean, 18 | dynamicColor: Boolean, 19 | content: @Composable () -> Unit 20 | ) { 21 | val colorScheme = when { 22 | dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { 23 | val context = LocalContext.current 24 | 25 | if (darkTheme) dynamicDarkColorScheme(context) else dynamicDarkColorScheme(context) 26 | } 27 | darkTheme -> DarkThemeColors 28 | else -> LightThemeColors 29 | } 30 | 31 | val view = LocalView.current 32 | if (!view.isInEditMode) { 33 | SideEffect { 34 | val window = (view.context as Activity).window 35 | 36 | window?.statusBarColor = colorScheme.primary.toArgb() 37 | WindowCompat.getInsetsController( 38 | window, 39 | view 40 | ).isAppearanceLightStatusBars = darkTheme 41 | } 42 | } 43 | 44 | CompositionLocalProvider( 45 | LocalSpacing provides Spacing() 46 | ) { 47 | MaterialTheme( 48 | colorScheme = colorScheme, 49 | content = content 50 | ) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/presentation/components/button/secondary/SecondaryButtonComponent.kt: -------------------------------------------------------------------------------- 1 | package presentation.components.button.secondary 2 | 3 | import androidx.compose.foundation.layout.Box 4 | import androidx.compose.foundation.layout.aspectRatio 5 | import androidx.compose.foundation.shape.CircleShape 6 | import androidx.compose.material3.MaterialTheme 7 | import androidx.compose.material3.Surface 8 | import androidx.compose.material3.Text 9 | import androidx.compose.runtime.Composable 10 | import androidx.compose.ui.Alignment 11 | import androidx.compose.ui.Modifier 12 | import androidx.compose.ui.graphics.Color 13 | import androidx.compose.ui.graphics.Shape 14 | import androidx.compose.ui.text.style.TextAlign 15 | import core.ButtonAction 16 | import core.ButtonAction.Companion.getColorByButton 17 | 18 | @Composable 19 | fun SecondaryButtonComponent( 20 | modifier: Modifier = Modifier, 21 | buttonAction: ButtonAction, 22 | shape: Shape = CircleShape, 23 | onClick: () -> Unit 24 | ) { 25 | val color = buttonAction.getColorByButton() 26 | 27 | SecondaryButtonComponent( 28 | modifier = modifier, 29 | actionText = buttonAction.displayText, 30 | color = color, 31 | shape = shape, 32 | onClick = onClick 33 | ) 34 | } 35 | 36 | @Composable 37 | private fun SecondaryButtonComponent( 38 | modifier: Modifier = Modifier, 39 | actionText: String, 40 | color: Color, 41 | shape: Shape = CircleShape, 42 | onClick: () -> Unit 43 | ) { 44 | Surface( 45 | modifier = modifier, 46 | shape = shape, 47 | color = color, 48 | onClick = onClick 49 | ) { 50 | Box( 51 | contentAlignment = Alignment.Center, 52 | ) { 53 | Text( 54 | text = actionText, 55 | style = MaterialTheme.typography.titleMedium, 56 | textAlign = TextAlign.Center, 57 | ) 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/presentation/components/text/AutoSizeText.kt: -------------------------------------------------------------------------------- 1 | package presentation.components.text 2 | 3 | import androidx.compose.foundation.layout.BoxWithConstraints 4 | import androidx.compose.foundation.layout.aspectRatio 5 | import androidx.compose.foundation.layout.width 6 | import androidx.compose.material3.LocalContentColor 7 | import androidx.compose.material3.LocalTextStyle 8 | import androidx.compose.material3.Surface 9 | import androidx.compose.material3.Text 10 | import androidx.compose.runtime.Composable 11 | import androidx.compose.ui.Alignment 12 | import androidx.compose.ui.Modifier 13 | import androidx.compose.ui.graphics.Color 14 | import androidx.compose.ui.platform.LocalDensity 15 | import androidx.compose.ui.text.style.TextAlign 16 | import androidx.compose.ui.unit.dp 17 | import core.presentation.theme.CalculatorTheme 18 | 19 | @Composable 20 | internal fun AutoSizeText( 21 | modifier: Modifier = Modifier, 22 | text: String, 23 | color: Color = LocalContentColor.current 24 | ) { 25 | BoxWithConstraints( 26 | modifier = modifier, 27 | contentAlignment = Alignment.Center 28 | ) { 29 | val maxSize = minOf(maxWidth, maxHeight) 30 | 31 | val fontSize = with(LocalDensity.current) { 32 | maxSize.toSp() * 0.5f 33 | } 34 | 35 | val style = LocalTextStyle.current.copy( 36 | fontSize = fontSize, 37 | textAlign = TextAlign.Center, 38 | ) 39 | 40 | Text( 41 | text = text, 42 | color = color, 43 | style = style, 44 | softWrap = false, 45 | maxLines = 1, 46 | modifier = Modifier 47 | ) 48 | } 49 | } 50 | 51 | @Composable 52 | private fun AutoSizeTextPreview() { 53 | CalculatorTheme { 54 | Surface { 55 | AutoSizeText( 56 | modifier = Modifier.width(150.dp).aspectRatio(1f), 57 | text = "+" 58 | ) 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /composeApp/src/androidMain/kotlin/previews/components/button/secondary/SecondaryButtonGridPreview.kt: -------------------------------------------------------------------------------- 1 | package previews.components.button.secondary 2 | 3 | import android.content.res.Configuration 4 | import androidx.compose.foundation.layout.fillMaxWidth 5 | import androidx.compose.foundation.layout.height 6 | import androidx.compose.material3.Surface 7 | import androidx.compose.runtime.* 8 | import androidx.compose.ui.Modifier 9 | import androidx.compose.ui.tooling.preview.Preview 10 | import androidx.compose.ui.tooling.preview.PreviewLightDark 11 | import androidx.compose.ui.unit.dp 12 | import core.presentation.theme.CalculatorTheme 13 | import model.AngleType 14 | import presentation.components.button.secondary.MoreSecondaryActionsItem 15 | import presentation.components.button.secondary.SecondaryButtonGrid 16 | 17 | @Composable 18 | @Preview( 19 | showBackground = true, 20 | group = "Button Grid" 21 | ) 22 | @Preview( 23 | showBackground = true, 24 | uiMode = Configuration.UI_MODE_NIGHT_YES, 25 | group = "Button Grid" 26 | ) 27 | private fun ButtonGridPreview() { 28 | var buttonGridExpanded by remember { mutableStateOf(true) } 29 | 30 | CalculatorTheme { 31 | Surface { 32 | SecondaryButtonGrid( 33 | modifier = Modifier 34 | .fillMaxWidth() 35 | .height(200.dp), 36 | isPortrait = true, 37 | buttonGridExpanded = buttonGridExpanded, 38 | angleType = AngleType.DEG, 39 | isInverse = false, 40 | onActionClick = {}, 41 | onMoreActionsClick = { buttonGridExpanded = !buttonGridExpanded } 42 | ) 43 | } 44 | } 45 | } 46 | 47 | @Composable 48 | @PreviewLightDark 49 | private fun MoreSecondaryActionsItemPreview() { 50 | var buttonGridExpanded by remember { mutableStateOf(false) } 51 | 52 | CalculatorTheme { 53 | Surface { 54 | MoreSecondaryActionsItem( 55 | onClick = { buttonGridExpanded = !buttonGridExpanded }, 56 | buttonGridExpanded = buttonGridExpanded, 57 | verticalContent = true 58 | ) 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/Project_Default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 41 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 31 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/presentation/components/button/primary/ButtonGrid.kt: -------------------------------------------------------------------------------- 1 | package presentation.components.button.primary 2 | 3 | import androidx.compose.foundation.layout.* 4 | import androidx.compose.material3.MaterialTheme 5 | import androidx.compose.material3.Surface 6 | import androidx.compose.runtime.Composable 7 | import androidx.compose.runtime.remember 8 | import androidx.compose.ui.Alignment 9 | import androidx.compose.ui.Modifier 10 | import androidx.compose.ui.unit.dp 11 | import core.ButtonAction 12 | import core.presentation.theme.CalculatorTheme 13 | import core.presentation.theme.spacing 14 | 15 | @Composable 16 | internal fun ButtonGrid( 17 | modifier: Modifier = Modifier, 18 | isPortrait: Boolean, 19 | onActionClick: (action: ButtonAction) -> Unit 20 | ) { 21 | val actions = ButtonAction.getAllPrimaryButtons(isPortrait = isPortrait) 22 | 23 | ButtonGridImpl( 24 | modifier = modifier, 25 | isPortrait = isPortrait, 26 | actions = actions, 27 | onActionClick = onActionClick 28 | ) 29 | } 30 | 31 | @Composable 32 | private fun ButtonGridImpl( 33 | modifier: Modifier = Modifier, 34 | isPortrait: Boolean, 35 | actions: List, 36 | onActionClick: (action: ButtonAction) -> Unit 37 | ) { 38 | val spaceSmall = MaterialTheme.spacing.small 39 | 40 | val actionsChunked = remember(actions, isPortrait) { 41 | val itemsPerCol = if (isPortrait) 4 else 5 42 | 43 | actions.chunked(itemsPerCol) 44 | } 45 | 46 | Column( 47 | modifier = modifier, 48 | verticalArrangement = Arrangement.spacedBy(spaceSmall), 49 | horizontalAlignment = Alignment.CenterHorizontally 50 | ) { 51 | actionsChunked.forEach { rowActions -> 52 | Row( 53 | modifier = Modifier.fillMaxWidth().weight(1f), 54 | horizontalArrangement = Arrangement.spacedBy(spaceSmall), 55 | verticalAlignment = Alignment.CenterVertically 56 | ) { 57 | rowActions.forEach { action -> 58 | ButtonComponent( 59 | modifier = Modifier.weight(1f), 60 | buttonAction = action, 61 | onClick = { onActionClick(action) } 62 | ) 63 | } 64 | } 65 | } 66 | } 67 | } 68 | 69 | @Composable 70 | private fun ButtonGridPreview() { 71 | CalculatorTheme { 72 | Surface { 73 | ButtonGrid( 74 | modifier = Modifier 75 | .fillMaxWidth() 76 | .height(400.dp), 77 | isPortrait = true, 78 | onActionClick = {} 79 | ) 80 | } 81 | } 82 | } -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/core/evaluator/internal/Expr.kt: -------------------------------------------------------------------------------- 1 | package core.evaluator.internal 2 | 3 | import java.math.BigDecimal 4 | 5 | internal sealed class Expr { 6 | abstract fun accept(visitor: ExprVisitor): R 7 | } 8 | 9 | internal class AssignExpr( 10 | val name: Token, 11 | val value: Expr 12 | ) : Expr() { 13 | override fun accept(visitor: ExprVisitor): R { 14 | return visitor.visitAssignExpr(this) 15 | } 16 | } 17 | 18 | internal class LogicalExpr( 19 | val left: Expr, 20 | val operator: Token, 21 | val right: Expr 22 | ) : Expr() { 23 | override fun accept(visitor: ExprVisitor): R { 24 | return visitor.visitLogicalExpr(this) 25 | } 26 | } 27 | 28 | internal class BinaryExpr( 29 | val left: Expr, 30 | val operator: Token, 31 | val right: Expr 32 | ) : Expr() { 33 | override fun accept(visitor: ExprVisitor): R { 34 | return visitor.visitBinaryExpr(this) 35 | } 36 | } 37 | 38 | internal class UnaryExpr( 39 | val operator: Token, 40 | val right: Expr 41 | ) : Expr() { 42 | override fun accept(visitor: ExprVisitor): R { 43 | return visitor.visitUnaryExpr(this) 44 | } 45 | } 46 | 47 | internal class LeftExpr( 48 | val operator: Token, 49 | val left: Expr 50 | ) : Expr() { 51 | override fun accept(visitor: ExprVisitor): R { 52 | return visitor.visitLeftExpr(this) 53 | } 54 | } 55 | 56 | internal class CallExpr( 57 | val name: String, 58 | val arguments: List 59 | ) : Expr() { 60 | override fun accept(visitor: ExprVisitor): R { 61 | return visitor.visitCallExpr(this) 62 | } 63 | } 64 | 65 | internal class LiteralExpr(val value: BigDecimal) : Expr() { 66 | override fun accept(visitor: ExprVisitor): R { 67 | return visitor.visitLiteralExpr(this) 68 | } 69 | } 70 | 71 | internal class VariableExpr(val name: Token) : Expr() { 72 | override fun accept(visitor: ExprVisitor): R { 73 | return visitor.visitVariableExpr(this) 74 | } 75 | } 76 | 77 | internal class GroupingExpr(val expression: Expr) : Expr() { 78 | override fun accept(visitor: ExprVisitor): R { 79 | return visitor.visitGroupingExpr(this) 80 | } 81 | } 82 | 83 | internal interface ExprVisitor { 84 | fun visitAssignExpr(expr: AssignExpr): R 85 | 86 | fun visitLogicalExpr(expr: LogicalExpr): R 87 | 88 | fun visitBinaryExpr(expr: BinaryExpr): R 89 | 90 | fun visitUnaryExpr(expr: UnaryExpr): R 91 | 92 | fun visitLeftExpr(expr: LeftExpr): R 93 | 94 | fun visitCallExpr(expr: CallExpr): R 95 | 96 | fun visitLiteralExpr(expr: LiteralExpr): R 97 | 98 | fun visitVariableExpr(expr: VariableExpr): R 99 | 100 | fun visitGroupingExpr(expr: GroupingExpr): R 101 | } -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/presentation/components/button/primary/ButtonComponent.kt: -------------------------------------------------------------------------------- 1 | package presentation.components.button.primary 2 | 3 | import androidx.compose.animation.core.LinearEasing 4 | import androidx.compose.animation.core.animateDpAsState 5 | import androidx.compose.animation.core.tween 6 | import androidx.compose.foundation.interaction.MutableInteractionSource 7 | import androidx.compose.foundation.interaction.collectIsPressedAsState 8 | import androidx.compose.foundation.layout.* 9 | import androidx.compose.foundation.shape.RoundedCornerShape 10 | import androidx.compose.material3.MaterialTheme 11 | import androidx.compose.material3.Surface 12 | import androidx.compose.material3.contentColorFor 13 | import androidx.compose.runtime.Composable 14 | import androidx.compose.runtime.getValue 15 | import androidx.compose.runtime.remember 16 | import androidx.compose.ui.Alignment 17 | import androidx.compose.ui.Modifier 18 | import androidx.compose.ui.graphics.Color 19 | import androidx.compose.ui.unit.dp 20 | import core.ButtonAction 21 | import core.ButtonAction.Companion.getColorByButton 22 | import core.presentation.theme.CalculatorTheme 23 | import core.presentation.theme.spacing 24 | import presentation.components.text.AutoSizeText 25 | 26 | @Composable 27 | internal fun ButtonComponent( 28 | modifier: Modifier = Modifier, 29 | buttonAction: ButtonAction, 30 | onClick: () -> Unit 31 | ) { 32 | val color = buttonAction.getColorByButton() 33 | 34 | ButtonComponent( 35 | modifier = modifier, 36 | text = buttonAction.value, 37 | surfaceColor = color, 38 | onClick = onClick 39 | ) 40 | } 41 | 42 | @Composable 43 | private fun ButtonComponent( 44 | modifier: Modifier = Modifier, 45 | text: String, 46 | surfaceColor: Color = MaterialTheme.colorScheme.surface, 47 | onClick: () -> Unit 48 | ) { 49 | val interactionSource = remember { MutableInteractionSource() } 50 | 51 | val surfacePressed by interactionSource.collectIsPressedAsState() 52 | val surfaceShape by animateDpAsState( 53 | targetValue = if (surfacePressed) SURFACE_PRESS_SHAPE else SURFACE_CIRCLE_SHAPE, 54 | animationSpec = tween(durationMillis = 250, easing = LinearEasing), 55 | label = "Surface Shape" 56 | ) 57 | 58 | Surface( 59 | modifier = modifier, 60 | shape = RoundedCornerShape(surfaceShape), 61 | tonalElevation = 1.dp, 62 | color = surfaceColor, 63 | onClick = onClick, 64 | interactionSource = interactionSource 65 | ) { 66 | Box( 67 | contentAlignment = Alignment.Center, 68 | modifier = Modifier.fillMaxSize() 69 | ) { 70 | AutoSizeText( 71 | text = text, 72 | modifier = Modifier.padding(8.dp), 73 | color = contentColorFor(backgroundColor = surfaceColor) 74 | ) 75 | } 76 | } 77 | } 78 | 79 | private val SURFACE_CIRCLE_SHAPE = 100.dp 80 | private val SURFACE_PRESS_SHAPE = 20.dp -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%" == "" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%" == "" set DIRNAME=. 29 | set APP_BASE_NAME=%~n0 30 | set APP_HOME=%DIRNAME% 31 | 32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 34 | 35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 37 | 38 | @rem Find java.exe 39 | if defined JAVA_HOME goto findJavaFromJavaHome 40 | 41 | set JAVA_EXE=java.exe 42 | %JAVA_EXE% -version >NUL 2>&1 43 | if "%ERRORLEVEL%" == "0" goto execute 44 | 45 | echo. 46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 47 | echo. 48 | echo Please set the JAVA_HOME variable in your environment to match the 49 | echo location of your Java installation. 50 | 51 | goto fail 52 | 53 | :findJavaFromJavaHome 54 | set JAVA_HOME=%JAVA_HOME:"=% 55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 56 | 57 | if exist "%JAVA_EXE%" goto execute 58 | 59 | echo. 60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 61 | echo. 62 | echo Please set the JAVA_HOME variable in your environment to match the 63 | echo location of your Java installation. 64 | 65 | goto fail 66 | 67 | :execute 68 | @rem Setup the command line 69 | 70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 71 | 72 | 73 | @rem Execute Gradle 74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 75 | 76 | :end 77 | @rem End local scope for the variables with windows NT shell 78 | if "%ERRORLEVEL%"=="0" goto mainEnd 79 | 80 | :fail 81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 82 | rem the _cmd.exe /c_ return code! 83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 84 | exit /b 1 85 | 86 | :mainEnd 87 | if "%OS%"=="Windows_NT" endlocal 88 | 89 | :omega 90 | -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | #005EB5 3 | #FFFFFF 4 | #D4E3FF 5 | #001B3D 6 | #555F71 7 | #FFFFFF 8 | #D9E3F8 9 | #121C2B 10 | #6E5675 11 | #FFFFFF 12 | #F8D8FE 13 | #27132F 14 | #BA1B1B 15 | #FFDAD4 16 | #FFFFFF 17 | #410001 18 | #FDFBFF 19 | #1B1B1D 20 | #FDFBFF 21 | #1B1B1D 22 | #E0E2EB 23 | #43474F 24 | #74777F 25 | #F1F0F4 26 | #2F3033 27 | #A5C8FF 28 | #000000 29 | #A5C8FF 30 | #A5C8FF 31 | #003063 32 | #00468A 33 | #D4E3FF 34 | #BDC7DC 35 | #273141 36 | #3D4758 37 | #D9E3F8 38 | #DBBDE1 39 | #3E2845 40 | #553E5D 41 | #F8D8FE 42 | #FFB4A9 43 | #930006 44 | #680003 45 | #FFDAD4 46 | #1B1B1D 47 | #E3E2E6 48 | #1B1B1D 49 | #E3E2E6 50 | #43474F 51 | #C3C6CF 52 | #8E919A 53 | #1B1B1D 54 | #E3E2E6 55 | #005EB5 56 | #000000 57 | #005EB5 58 | -------------------------------------------------------------------------------- /composeApp/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import org.jetbrains.compose.desktop.application.dsl.TargetFormat 2 | import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi 3 | import org.jetbrains.kotlin.gradle.dsl.JvmTarget 4 | 5 | plugins { 6 | alias(libs.plugins.kotlin.multiplatform) 7 | alias(libs.plugins.android.application) 8 | alias(libs.plugins.jetbrainsCompose) 9 | alias(libs.plugins.compose.compiler) 10 | alias(libs.plugins.ksp) 11 | } 12 | 13 | kotlin { 14 | androidTarget { 15 | @OptIn(ExperimentalKotlinGradlePluginApi::class) 16 | compilerOptions { 17 | jvmTarget.set(JvmTarget.JVM_17) 18 | freeCompilerArgs.add("-opt-in=kotlin.RequiresOptIn") 19 | } 20 | } 21 | 22 | jvm("desktop") 23 | 24 | jvmToolchain(17) 25 | 26 | sourceSets { 27 | commonMain.dependencies { 28 | implementation(compose.runtime) 29 | implementation(compose.foundation) 30 | implementation(compose.material3) 31 | implementation(compose.ui) 32 | 33 | api(project.dependencies.platform(libs.koin.bom)) 34 | api(libs.koin.core) 35 | implementation(libs.koin.compose) 36 | implementation(libs.koin.compose.viewmodel) 37 | 38 | implementation(libs.constraintlayout.compose.multiplatform) 39 | implementation(libs.material3.windowSizeClass.multiplatform) 40 | 41 | implementation(libs.slf4j.api) 42 | implementation(libs.slf4j.simple) 43 | implementation(libs.kotlinLogging) 44 | 45 | implementation(libs.kotlinx.datetime) 46 | } 47 | 48 | commonTest.dependencies { 49 | implementation(kotlin("test")) 50 | implementation(kotlin("test-annotations-common")) 51 | implementation(libs.assertk) 52 | 53 | implementation(libs.kotlinx.coroutines.test) 54 | } 55 | 56 | val desktopMain by getting { 57 | dependencies { 58 | implementation(compose.desktop.currentOs) 59 | 60 | implementation(libs.koin.logger.slf4j) 61 | 62 | implementation(libs.kotlinx.coroutines.swing) 63 | } 64 | } 65 | 66 | androidMain.dependencies { 67 | implementation(libs.koin.android) 68 | 69 | implementation(libs.kotlinx.coroutines.android) 70 | 71 | implementation(libs.androidx.activity.compose) 72 | } 73 | } 74 | } 75 | 76 | android { 77 | namespace = "me.joaomanaia.calculator.compose" 78 | compileSdk = libs.versions.android.compileSdk.get().toInt() 79 | 80 | sourceSets["main"].manifest.srcFile("src/androidMain/AndroidManifest.xml") 81 | sourceSets["main"].res.srcDirs("src/androidMain/res") 82 | sourceSets["main"].resources.srcDirs("src/commonMain/resources") 83 | 84 | defaultConfig { 85 | applicationId = "me.joaomanaia.calculator.compose" 86 | minSdk = libs.versions.android.minSdk.get().toInt() 87 | targetSdk = libs.versions.android.targetSdk.get().toInt() 88 | versionCode = libs.versions.app.version.code.get().toInt() 89 | versionName = libs.versions.app.version.name.get() 90 | } 91 | packaging { 92 | resources { 93 | excludes += "/META-INF/{AL2.0,LGPL2.1}" 94 | } 95 | } 96 | buildTypes { 97 | getByName("release") { 98 | isMinifyEnabled = false 99 | } 100 | } 101 | buildFeatures { 102 | compose = true 103 | } 104 | compileOptions { 105 | sourceCompatibility = JavaVersion.VERSION_17 106 | targetCompatibility = JavaVersion.VERSION_17 107 | } 108 | dependencies { 109 | debugImplementation(compose.uiTooling) 110 | debugImplementation(compose.preview) 111 | } 112 | } 113 | 114 | compose.desktop { 115 | application { 116 | mainClass = "MainKt" 117 | 118 | nativeDistributions { 119 | targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) 120 | packageName = "Calculator" 121 | packageVersion = libs.versions.app.version.name.get() 122 | } 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /.github/workflows/android-ci.yml: -------------------------------------------------------------------------------- 1 | name: Android CI 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: [ "main" ] 7 | pull_request: 8 | branches: [ "main" ] 9 | 10 | env: 11 | JAVA_VERSION: "17" 12 | JAVA_DISTR: 'corretto' 13 | 14 | jobs: 15 | test: 16 | name: "🤖 Unit Tests" 17 | runs-on: ubuntu-20.04 18 | 19 | steps: 20 | - name: Checkout sources 21 | uses: actions/checkout@v3 22 | 23 | - name: Set up JDK 24 | uses: actions/setup-java@v3 25 | with: 26 | distribution: ${{ env.JAVA_DISTR }} 27 | java-version: ${{ env.JAVA_VERSION }} 28 | 29 | - name: Run unit tests 30 | uses: gradle/gradle-build-action@v2.3.3 31 | with: 32 | arguments: > 33 | testDebugUnitTest 34 | lint_off: 35 | name: "🔍 Android Lint" 36 | runs-on: ubuntu-latest 37 | 38 | steps: 39 | - name: Checkout sources 40 | uses: actions/checkout@v3 41 | 42 | - name: Set up JDK 43 | uses: actions/setup-java@v3 44 | with: 45 | distribution: ${{ env.JAVA_DISTR }} 46 | java-version: ${{ env.JAVA_VERSION }} 47 | 48 | - name: Increase gradle daemon memory 49 | run: "echo \"org.gradle.jvmargs=-Xmx4096m\" >> gradle.properties" 50 | 51 | - name: Lint sources 52 | uses: gradle/gradle-build-action@v2.3.3 53 | with: 54 | arguments: lint --stacktrace 55 | 56 | - name: Generate GitHub annotations 57 | uses: yutailang0119/action-android-lint@v3 58 | with: 59 | report-path: ./app/build/reports/lint-results.xml 60 | 61 | assemble_apk: 62 | name: "📦 Assemble APKs" 63 | needs: 64 | - test 65 | - lint_off 66 | runs-on: ubuntu-latest 67 | 68 | steps: 69 | - name: Checkout sources 70 | uses: actions/checkout@v3 71 | 72 | - name: Set up JDK 73 | uses: actions/setup-java@v3 74 | with: 75 | distribution: ${{ env.JAVA_DISTR }} 76 | java-version: ${{ env.JAVA_VERSION }} 77 | 78 | - name: Assemble debug APKs 79 | uses: gradle/gradle-build-action@v2.3.3 80 | with: 81 | arguments: assembleDebug 82 | 83 | - name: Upload APKs 84 | uses: actions/upload-artifact@v3 85 | with: 86 | name: build-artifacts.zip 87 | path: | 88 | app/build/outputs/apk/debug/app-debug.apk 89 | calculator_uploads/build/outputs/aar 90 | 91 | instrumented-test: 92 | name: "🧪 Instrumented tests" 93 | needs: 94 | - assemble_apk 95 | runs-on: macos-latest 96 | strategy: 97 | matrix: 98 | api-level: [29] 99 | steps: 100 | - name: checkout 101 | uses: actions/checkout@v3 102 | 103 | - name: Gradle cache 104 | uses: gradle/gradle-build-action@v2 105 | 106 | - name: Set up JDK 107 | uses: actions/setup-java@v3 108 | with: 109 | distribution: ${{ env.JAVA_DISTR }} 110 | java-version: ${{ env.JAVA_VERSION }} 111 | 112 | - name: AVD cache 113 | uses: actions/cache@v3 114 | id: avd-cache 115 | with: 116 | path: | 117 | ~/.android/avd/* 118 | ~/.android/adb* 119 | key: avd-${{ matrix.api-level }} 120 | 121 | - name: create AVD and generate snapshot for caching 122 | if: steps.avd-cache.outputs.cache-hit != 'true' 123 | uses: reactivecircus/android-emulator-runner@v2 124 | with: 125 | api-level: ${{ matrix.api-level }} 126 | force-avd-creation: false 127 | emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none 128 | disable-animations: false 129 | script: echo "Generated AVD snapshot for caching." 130 | 131 | - name: run tests 132 | uses: reactivecircus/android-emulator-runner@v2 133 | with: 134 | api-level: ${{ matrix.api-level }} 135 | force-avd-creation: false 136 | emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none 137 | disable-animations: true 138 | script: ./gradlew connectedCheck 139 | -------------------------------------------------------------------------------- /composeApp/src/commonTest/kotlin/core/util/ExpressionUtilImplTest.kt: -------------------------------------------------------------------------------- 1 | package core.util 2 | 3 | import androidx.compose.ui.text.TextRange 4 | import androidx.compose.ui.text.input.TextFieldValue 5 | import assertk.assertThat 6 | import assertk.assertions.isEqualTo 7 | import core.ButtonAction 8 | import core.evaluator.Expressions 9 | import core.evaluator.internal.Evaluator 10 | import org.junit.jupiter.params.ParameterizedTest 11 | import org.junit.jupiter.params.provider.CsvSource 12 | import kotlin.test.BeforeTest 13 | import kotlin.test.Test 14 | 15 | class ExpressionUtilTest { 16 | private lateinit var expressionUtil: ExpressionUtil 17 | 18 | @BeforeTest 19 | fun setup() { 20 | val expressions = Expressions(evaluator = Evaluator()) 21 | expressionUtil = ExpressionUtilImpl(expressions) 22 | } 23 | 24 | private fun basicTextField( 25 | text: String, 26 | range: IntRange = text.length..text.length 27 | ) = TextFieldValue( 28 | text = text, 29 | selection = TextRange(range.first, range.last) 30 | ) 31 | 32 | @ParameterizedTest(name = "add parentheses at cursor {1} to {2}: {0} -> {3}") 33 | @CsvSource( 34 | "'', 0, 0, (, 1", 35 | "1+2*3, 0, 0, (1+2*3, 1", 36 | "1+2*3, 1, 1, 1(+2*3, 2", 37 | "1+2*3, 5, 5, 1+2*3(, 6", 38 | "1+2*3(, 6, 6, 1+2*3((, 7", 39 | "1+2*3((5, 8, 8, 1+2*3((5), 9", 40 | "1+2*3((5), 9, 9, 1+2*3((5)), 10", 41 | "1+2*3, 0, 3, (1+2)*3, 5", 42 | ) 43 | fun `correct add parentheses test`( 44 | expressionText: String, 45 | cursorPosStart: Int, 46 | cursorPosEnd: Int, 47 | expectedText: String, 48 | expectedCursorPos: Int 49 | ) { 50 | val expression = TextFieldValue( 51 | text = expressionText, 52 | selection = TextRange(cursorPosStart, cursorPosEnd) 53 | ) 54 | 55 | val newExpression = expressionUtil.addParentheses(expression) 56 | 57 | assertThat(newExpression.text).isEqualTo(expectedText) 58 | assertThat(newExpression.selection).isEqualTo(TextRange(expectedCursorPos)) 59 | } 60 | 61 | @ParameterizedTest 62 | @CsvSource( 63 | "'', ''", 64 | "1+2*3, 7", 65 | "1+2*3((5), ''", // If there is an open parenthesis without a close parenthesis, do not calculate 66 | ) 67 | fun `calculate expression test`( 68 | expressionText: String, 69 | expected: String 70 | ) { 71 | val expression = expressionUtil.calculateExpression(expressionText) 72 | assertThat(expression).isEqualTo(expected) 73 | } 74 | 75 | @ParameterizedTest 76 | @CsvSource( 77 | "'', 0, 0, ''", 78 | "1+2*3, 0, 0, 1+2*3", 79 | "1+2*3, 5, 5, 1+2*", 80 | "1+2*3, 0, 3, *3", 81 | "1+2*3, 0, 5, ''", 82 | "2cos, 4, 4, 2", // When removing inside the function, remove the function too 83 | "2cos(, 3, 3, 2", // When removing inside the function, remove the function too 84 | "2cos(, 5, 5, 2", // When removing the parenthesis with function, remove the function too 85 | "2cos((, 6, 6, 2cos(", 86 | "2cos(, 2, 4, 2", // When selecting part of the function, remove all of it 87 | "2cos(2)+cos(3)*8cos(5), 9, 9, 2cos(2)+3)*8cos(5)", // Should remove the middle function and the parenthesis 88 | ) 89 | fun `remove digit test`( 90 | expressionText: String, 91 | cursorPosStart: Int, 92 | cursorPosEnd: Int, 93 | expected: String 94 | ) { 95 | val expression = basicTextField(text = expressionText, range = cursorPosStart..cursorPosEnd) 96 | val newExpression = expressionUtil.removeDigit(expression) 97 | assertThat(newExpression.text).isEqualTo(expected) 98 | } 99 | 100 | @Test 101 | fun `add actions to expression test`() { 102 | val expression1 = basicTextField("") 103 | val newExpression1 = expressionUtil.addActionValueToExpression(ButtonAction.Button0, expression1) 104 | assertThat(newExpression1.text).isEqualTo("0") 105 | 106 | val expression2 = basicTextField("(") 107 | val newExpression2 = expressionUtil.addActionValueToExpression(ButtonAction.Button0, expression2) 108 | assertThat(newExpression2.text).isEqualTo("(0") 109 | 110 | val expression3 = basicTextField("2+4") 111 | val newExpression3 = expressionUtil.addActionValueToExpression(ButtonAction.ButtonPlus, expression3) 112 | assertThat(newExpression3.text).isEqualTo("2+4+") 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/core/evaluator/internal/Scanner.kt: -------------------------------------------------------------------------------- 1 | package core.evaluator.internal 2 | 3 | import core.evaluator.internal.TokenType.* 4 | import java.math.MathContext 5 | 6 | private fun invalidToken(c: Char) { 7 | throw ExpressionException("Invalid token '$c'") 8 | } 9 | 10 | internal class Scanner( 11 | private val source: String, 12 | private val mathContext: MathContext 13 | ) { 14 | private val tokens: MutableList = mutableListOf() 15 | private var start = 0 16 | private var current = 0 17 | 18 | fun scanTokens(): List { 19 | while (!isAtEnd()) scanToken() 20 | 21 | tokens.add(Token(EOF, "", null)) 22 | return tokens 23 | } 24 | 25 | private fun isAtEnd() = current >= source.length 26 | 27 | private fun scanToken() { 28 | start = current 29 | 30 | when (val c = advance()) { 31 | ' ', 32 | '\r', 33 | '\t' -> { 34 | // Ignore whitespace. 35 | } 36 | 37 | '+' -> addToken(PLUS) 38 | '-' -> addToken(MINUS) 39 | '*' -> addToken(STAR) 40 | '/' -> addToken(SLASH) 41 | '%' -> addToken(MODULO) 42 | '^' -> addToken(EXPONENT) 43 | '=' -> if (match('=')) addToken(EQUAL_EQUAL) else addToken(ASSIGN) 44 | '!' -> if (match('=')) addToken(NOT_EQUAL) else addToken(FACTORIAL) 45 | '>' -> if (match('=')) addToken(GREATER_EQUAL) else addToken(GREATER) 46 | '<' -> if (match('=')) addToken(LESS_EQUAL) else addToken(LESS) 47 | '|' -> if (match('|')) addToken(BAR_BAR) else invalidToken(c) 48 | '&' -> if (match('&')) addToken(AMP_AMP) else invalidToken(c) 49 | ',' -> addToken(COMMA) 50 | '(' -> addToken(LEFT_PAREN) 51 | ')' -> addToken(RIGHT_PAREN) 52 | else -> { 53 | when { 54 | c.isDigit() -> number() 55 | c.isAlpha() || c.isOtherIdentifiers() -> identifier() 56 | else -> invalidToken(c) 57 | } 58 | } 59 | } 60 | } 61 | 62 | private fun isDigit( 63 | char: Char, 64 | previousChar: Char = '\u0000', 65 | nextChar: Char = '\u0000' 66 | ): Boolean { 67 | return char.isDigit() || when (char) { 68 | '.' -> true 69 | 'e', 'E' -> previousChar.isDigit() && (nextChar.isDigit() || nextChar == '+' || nextChar == '-') 70 | '+', '-' -> (previousChar == 'e' || previousChar == 'E') && nextChar.isDigit() 71 | else -> false 72 | } 73 | } 74 | 75 | private fun number() { 76 | while (peek().isDigit()) advance() 77 | 78 | if (isDigit(peek(), peekPrevious(), peekNext())) { 79 | advance() 80 | while (isDigit(peek(), peekPrevious(), peekNext())) advance() 81 | } 82 | 83 | val value = source 84 | .substring(start, current) 85 | .toBigDecimal(mathContext) 86 | 87 | addToken(NUMBER, value) 88 | } 89 | 90 | private fun identifier() { 91 | while (peek().isAlphaNumeric()) advance() 92 | 93 | addToken(IDENTIFIER) 94 | } 95 | 96 | private fun advance() = source[current++] 97 | 98 | private fun peek(): Char = if (isAtEnd()) '\u0000' else source[current] 99 | 100 | private fun peekPrevious(): Char = if (current > 0) source[current - 1] else '\u0000' 101 | 102 | private fun peekNext(): Char { 103 | return if (current + 1 >= source.length) { 104 | '\u0000' 105 | } else { 106 | source[current + 1] 107 | } 108 | } 109 | 110 | private fun match(expected: Char): Boolean { 111 | if (isAtEnd()) return false 112 | if (source[current] != expected) return false 113 | 114 | current++ 115 | return true 116 | } 117 | 118 | private fun addToken(type: TokenType) = addToken(type, null) 119 | 120 | private fun addToken(type: TokenType, literal: Any?) { 121 | val text = source.substring(start, current) 122 | tokens.add(Token(type, text, literal)) 123 | } 124 | 125 | private fun Char.isAlphaNumeric() = isAlpha() || isDigit() || isOtherIdentifiers() 126 | 127 | private fun Char.isAlpha() = this in 'a'..'z' 128 | || this in 'A'..'Z' 129 | || this == '_' 130 | 131 | private fun Char.isOtherIdentifiers() = this == 'π' 132 | || this == '√' 133 | || this == '!' 134 | 135 | private fun Char.isDigit() = this == '.' || this in '0'..'9' 136 | } 137 | -------------------------------------------------------------------------------- /gradle/libs.versions.toml: -------------------------------------------------------------------------------- 1 | [versions] 2 | 3 | app-version-name = "1.0.0" 4 | app-version-code = "1" 5 | android-compileSdk = "34" 6 | android-minSdk = "24" 7 | android-targetSdk = "34" 8 | 9 | assertk = "0.28.0" 10 | androidxCoreKtx = "1.12.0" 11 | androidxActivity = "1.8.2" 12 | androidGradlePlugin = "8.5.1" 13 | androidxComposeCompiler = "1.5.10" 14 | androidxComposeBom = "2024.06.00" 15 | constraintlayoutCompose = "1.0.1" 16 | kmpViewmodelKoinCompose = "0.7.1" 17 | kotlin = "2.0.0" 18 | kotlinxCoroutines = "1.8.0" 19 | kotlinxDatetime = "0.6.0" 20 | ksp = "2.0.0-1.0.22" 21 | koin-bom = "3.6.0-Beta4" 22 | koinComposeMultiplatform = "1.2.0-Beta4" 23 | googleMaterial = "1.11.0" 24 | junitJupiter = "5.10.2" 25 | jetbrainsCompose = "1.6.11" 26 | constraintlayoutComposeMultiplatform = "0.3.1" 27 | material3WindowSizeClassMultiplatform = "0.5.0" 28 | kotlinLogging = "7.0.0" 29 | slf4j = "2.0.13" 30 | 31 | [libraries] 32 | 33 | assertk = { module = "com.willowtreeapps.assertk:assertk", version.ref = "assertk" } 34 | androidx-constraintlayout-compose = { module = "androidx.constraintlayout:constraintlayout-compose", version.ref = "constraintlayoutCompose" } 35 | androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "androidxCoreKtx" } 36 | androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "androidxActivity" } 37 | androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "androidxComposeBom" } 38 | androidx-compose-foundation = { group = "androidx.compose.foundation", name = "foundation" } 39 | androidx-compose-foundation-layout = { group = "androidx.compose.foundation", name = "foundation-layout" } 40 | androidx-compose-material-iconsExtended = { group = "androidx.compose.material", name = "material-icons-extended" } 41 | androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" } 42 | androidx-compose-material3-windowSizeClass = { group = "androidx.compose.material3", name = "material3-window-size-class" } 43 | androidx-compose-runtime = { group = "androidx.compose.runtime", name = "runtime" } 44 | androidx-compose-runtime-livedata = { group = "androidx.compose.runtime", name = "runtime-livedata" } 45 | androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" } 46 | androidx-compose-ui-test = { group = "androidx.compose.ui", name = "ui-test-junit4" } 47 | androidx-compose-ui-testManifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } 48 | androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } 49 | androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } 50 | constraintlayout-compose-multiplatform = { module = "tech.annexflow.compose:constraintlayout-compose-multiplatform", version.ref = "constraintlayoutComposeMultiplatform" } 51 | kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "kotlinxCoroutines" } 52 | kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" } 53 | kotlinx-coroutines-swing = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-swing", version.ref = "kotlinxCoroutines" } 54 | kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" } 55 | koin-bom = { module = "io.insert-koin:koin-bom", version.ref = "koin-bom" } 56 | koin-android = { module = "io.insert-koin:koin-android" } 57 | koin-core = { module = "io.insert-koin:koin-core" } 58 | koin-logger-slf4j = { module = "io.insert-koin:koin-logger-slf4j" } 59 | koin-compose = { module = "io.insert-koin:koin-compose" } 60 | koin-compose-viewmodel = { module = "io.insert-koin:koin-compose-viewmodel", version.ref = "koinComposeMultiplatform" } 61 | google-material = { group = "com.google.android.material", name = "material", version.ref = "googleMaterial" } 62 | junit-jupiter-params = { group = "org.junit.jupiter", name = "junit-jupiter-params", version.ref = "junitJupiter" } 63 | kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinxDatetime" } 64 | material3-windowSizeClass-multiplatform = { module = "dev.chrisbanes.material3:material3-window-size-class-multiplatform", version.ref = "material3WindowSizeClassMultiplatform" } 65 | kotlinLogging = { module = "io.github.oshai:kotlin-logging", version.ref = "kotlinLogging" } 66 | slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "slf4j" } 67 | slf4j-simple = { module = "org.slf4j:slf4j-simple", version.ref = "slf4j" } 68 | 69 | [plugins] 70 | 71 | android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" } 72 | compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } 73 | kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } 74 | ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } 75 | jetbrainsCompose = { id = "org.jetbrains.compose", version.ref = "jetbrainsCompose" } 76 | dependencyUpdates = { id = "com.github.ben-manes.versions", version = "0.51.0" } 77 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/presentation/home/components/HistoryComponent.kt: -------------------------------------------------------------------------------- 1 | package presentation.home.components 2 | 3 | import androidx.compose.foundation.ExperimentalFoundationApi 4 | import androidx.compose.foundation.clickable 5 | import androidx.compose.foundation.layout.* 6 | import androidx.compose.foundation.lazy.LazyColumn 7 | import androidx.compose.foundation.lazy.items 8 | import androidx.compose.foundation.shape.RoundedCornerShape 9 | import androidx.compose.material.icons.Icons 10 | import androidx.compose.material.icons.rounded.MoreVert 11 | import androidx.compose.material3.* 12 | import androidx.compose.runtime.Composable 13 | import androidx.compose.runtime.remember 14 | import androidx.compose.ui.Alignment 15 | import androidx.compose.ui.Modifier 16 | import androidx.compose.ui.text.style.TextAlign 17 | import androidx.compose.ui.unit.dp 18 | import core.presentation.theme.spacing 19 | import domain.result.ExpressionResult 20 | import domain.time.DateTimeUtil 21 | 22 | @OptIn(ExperimentalFoundationApi::class) 23 | @Composable 24 | internal fun HistoryList( 25 | modifier: Modifier = Modifier, 26 | results: List, 27 | insertIntoExpression: (String) -> Unit 28 | ) { 29 | val spaceMedium = MaterialTheme.spacing.medium 30 | 31 | Surface( 32 | modifier = modifier, 33 | tonalElevation = 4.dp, 34 | shape = RoundedCornerShape(20.dp) 35 | ) { 36 | Column( 37 | modifier = Modifier.fillMaxSize() 38 | ) { 39 | Surface( 40 | tonalElevation = 8.dp 41 | ) { 42 | Row( 43 | modifier = Modifier 44 | .fillMaxWidth() 45 | .padding( 46 | vertical = MaterialTheme.spacing.small, 47 | horizontal = MaterialTheme.spacing.medium 48 | ), 49 | verticalAlignment = Alignment.CenterVertically, 50 | horizontalArrangement = Arrangement.SpaceBetween 51 | ) { 52 | Text( 53 | text = "History", 54 | style = MaterialTheme.typography.bodyLarge 55 | ) 56 | IconButton( 57 | onClick = { /*TODO*/ }, 58 | ) { 59 | Icon( 60 | imageVector = Icons.Rounded.MoreVert, 61 | contentDescription = "History more options", 62 | ) 63 | } 64 | } 65 | } 66 | 67 | val resultsGrouped = remember(results) { 68 | results.groupBy { DateTimeUtil.formatDate(it.createdAt) } 69 | } 70 | 71 | LazyColumn( 72 | modifier = Modifier.fillMaxWidth(), 73 | contentPadding = PaddingValues( 74 | top = MaterialTheme.spacing.medium 75 | ) 76 | ) { 77 | resultsGrouped.forEach { (date, results) -> 78 | stickyHeader { 79 | Text( 80 | text = date, 81 | style = MaterialTheme.typography.titleMedium, 82 | color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f), 83 | modifier = Modifier.padding( 84 | start = spaceMedium, 85 | end = spaceMedium, 86 | bottom = spaceMedium 87 | ) 88 | ) 89 | } 90 | 91 | items(results) { result -> 92 | Text( 93 | text = result.expression, 94 | style = MaterialTheme.typography.titleLarge, 95 | textAlign = TextAlign.End, 96 | modifier = Modifier 97 | .fillMaxWidth() 98 | .padding(top = spaceMedium) 99 | .clickable { insertIntoExpression(result.expression) } 100 | .padding( 101 | vertical = MaterialTheme.spacing.tiny, 102 | horizontal = spaceMedium 103 | ) 104 | ) 105 | Text( 106 | text = result.result, 107 | style = MaterialTheme.typography.titleLarge, 108 | color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f), 109 | textAlign = TextAlign.End, 110 | modifier = Modifier 111 | .fillMaxWidth() 112 | .clickable { insertIntoExpression(result.result) } 113 | .padding( 114 | vertical = MaterialTheme.spacing.tiny, 115 | horizontal = spaceMedium 116 | ) 117 | ) 118 | } 119 | } 120 | } 121 | } 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/core/evaluator/internal/Evaluator.kt: -------------------------------------------------------------------------------- 1 | package core.evaluator.internal 2 | 3 | import core.evaluator.internal.TokenType.* 4 | import java.math.BigDecimal 5 | import java.math.MathContext 6 | import java.math.RoundingMode 7 | import kotlin.math.pow 8 | 9 | internal class Evaluator : ExprVisitor { 10 | internal var mathContext: MathContext = MathContext.DECIMAL64 11 | 12 | private val variables: LinkedHashMap = linkedMapOf() 13 | private val functions: MutableMap = mutableMapOf() 14 | 15 | private fun define(name: String, value: BigDecimal) { 16 | variables += name to value 17 | } 18 | 19 | fun define(name: String, expr: Expr): Evaluator { 20 | define(name.lowercase(), eval(expr)) 21 | return this 22 | } 23 | 24 | fun addFunction(name: String, function: Function): Evaluator { 25 | functions += name.lowercase() to function 26 | return this 27 | } 28 | 29 | fun removeFunction(name: String): Evaluator { 30 | functions.remove(name.lowercase()) 31 | return this 32 | } 33 | 34 | fun replaceFunction(name: String, function: Function): Evaluator { 35 | return this.removeFunction(name).addFunction(name, function) 36 | } 37 | 38 | fun eval(expr: Expr): BigDecimal { 39 | return expr.accept(this) 40 | } 41 | 42 | override fun visitAssignExpr(expr: AssignExpr): BigDecimal { 43 | val value = eval(expr.value) 44 | 45 | define(expr.name.lexeme, value) 46 | 47 | return value 48 | } 49 | 50 | override fun visitLogicalExpr(expr: LogicalExpr): BigDecimal { 51 | val left = expr.left 52 | val right = expr.right 53 | 54 | return when (expr.operator.type) { 55 | BAR_BAR -> left or right 56 | AMP_AMP -> left and right 57 | else -> throw ExpressionException("Invalid logical operator '${expr.operator.lexeme}'") 58 | } 59 | } 60 | 61 | override fun visitBinaryExpr(expr: BinaryExpr): BigDecimal { 62 | val left = eval(expr.left) 63 | val right = eval(expr.right) 64 | 65 | return when (expr.operator.type) { 66 | PLUS -> left + right 67 | MINUS -> left - right 68 | STAR -> left * right 69 | SLASH -> left.divide(right, mathContext) 70 | MODULO -> left.remainder(right, mathContext) 71 | EXPONENT -> left pow right 72 | EQUAL_EQUAL -> (left == right).toBigDecimal() 73 | NOT_EQUAL -> (left != right).toBigDecimal() 74 | GREATER -> (left > right).toBigDecimal() 75 | GREATER_EQUAL -> (left >= right).toBigDecimal() 76 | LESS -> (left < right).toBigDecimal() 77 | LESS_EQUAL -> (left <= right).toBigDecimal() 78 | else -> throw ExpressionException("Invalid binary operator '${expr.operator.lexeme}'") 79 | } 80 | } 81 | 82 | private fun Int.factorial() = (2..this).fold(1, Int::times) 83 | 84 | override fun visitUnaryExpr(expr: UnaryExpr): BigDecimal { 85 | val right = eval(expr.right) 86 | 87 | return if (expr.operator.type == MINUS) { 88 | right.negate() 89 | } else { 90 | throw ExpressionException("Invalid unary operator") 91 | } 92 | } 93 | 94 | override fun visitLeftExpr(expr: LeftExpr): BigDecimal { 95 | val left = eval(expr.left) 96 | 97 | return when (expr.operator.type) { 98 | FACTORIAL -> left.intValueExact().factorial().toBigDecimal() 99 | else -> throw ExpressionException("Invalid left operator") 100 | } 101 | } 102 | 103 | override fun visitCallExpr(expr: CallExpr): BigDecimal { 104 | val name = expr.name 105 | val function = 106 | functions[name.lowercase()] ?: throw ExpressionException("Undefined function '$name'") 107 | 108 | return function.call(expr.arguments.map { eval(it) }) 109 | } 110 | 111 | override fun visitLiteralExpr(expr: LiteralExpr): BigDecimal { 112 | return expr.value 113 | } 114 | 115 | override fun visitVariableExpr(expr: VariableExpr): BigDecimal { 116 | val name = expr.name.lexeme 117 | 118 | return variables[name.lowercase()] 119 | ?: throw ExpressionException("Undefined variable '$name'") 120 | } 121 | 122 | override fun visitGroupingExpr(expr: GroupingExpr): BigDecimal { 123 | return eval(expr.expression) 124 | } 125 | 126 | private infix fun Expr.or(right: Expr): BigDecimal { 127 | val left = eval(this) 128 | 129 | // short-circuit if left is truthy 130 | if (left.isTruthy()) return BigDecimal.ONE 131 | 132 | return eval(right).isTruthy().toBigDecimal() 133 | } 134 | 135 | private infix fun Expr.and(right: Expr): BigDecimal { 136 | val left = eval(this) 137 | 138 | // short-circuit if left is falsey 139 | if (!left.isTruthy()) return BigDecimal.ZERO 140 | 141 | return eval(right).isTruthy().toBigDecimal() 142 | } 143 | 144 | private fun BigDecimal.isTruthy(): Boolean { 145 | return this != BigDecimal.ZERO 146 | } 147 | 148 | private fun Boolean.toBigDecimal(): BigDecimal { 149 | return if (this) BigDecimal.ONE else BigDecimal.ZERO 150 | } 151 | 152 | private infix fun BigDecimal.pow(n: BigDecimal): BigDecimal { 153 | var right = n 154 | val signOfRight = right.signum() 155 | right = right.multiply(signOfRight.toBigDecimal()) 156 | val remainderOfRight = right.remainder(BigDecimal.ONE) 157 | val n2IntPart = right.subtract(remainderOfRight) 158 | val intPow = pow(n2IntPart.intValueExact(), mathContext) 159 | val doublePow = BigDecimal(toDouble().pow(remainderOfRight.toDouble())) 160 | 161 | var result = intPow.multiply(doublePow, mathContext) 162 | if (signOfRight == -1) result = BigDecimal 163 | .ONE.divide(result, mathContext.precision, RoundingMode.HALF_UP) 164 | 165 | return result 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/core/util/ExpressionUtilImpl.kt: -------------------------------------------------------------------------------- 1 | package core.util 2 | 3 | import androidx.compose.ui.text.TextRange 4 | import androidx.compose.ui.text.input.TextFieldValue 5 | import core.ButtonAction 6 | import core.evaluator.Expressions 7 | 8 | internal class ExpressionUtilImpl( 9 | private val expressions: Expressions 10 | ) : ExpressionUtil { 11 | companion object { 12 | val angleFunctions = setOf("sin", "sin⁻¹", "cos", "cos⁻¹", "tan", "tan⁻¹") 13 | private val functions = angleFunctions + setOf("log", "ln") 14 | } 15 | 16 | override fun addParentheses(currentExpression: TextFieldValue): TextFieldValue { 17 | val currentExpressionText = currentExpression.text 18 | 19 | // If there is a selection, add parentheses around the selected text 20 | if (currentExpression.selection.length > 0) { 21 | // Get the selected text 22 | val selectedText = currentExpressionText.substring( 23 | currentExpression.selection.start, 24 | currentExpression.selection.end 25 | ) 26 | 27 | return currentExpression.copy( 28 | // Replace selected text with parentheses around it 29 | text = currentExpression.text.replace(selectedText, "($selectedText)"), 30 | selection = TextRange(currentExpression.selection.end + 2) 31 | ) 32 | } 33 | 34 | val openParenthesesNum = currentExpressionText.count { it == '(' } 35 | val closeParenthesesNum = currentExpressionText.count { it == ')' } 36 | 37 | val previousChar = currentExpressionText.getOrNull(currentExpression.selection.start - 1) 38 | 39 | // Check if previous character is a digit, close parentheses, e or π 40 | // If true, add close parentheses 41 | val shouldAddCloseParentheses = previousChar?.digitToIntOrNull() != null 42 | || previousChar == ')' 43 | || previousChar == 'e' 44 | || previousChar == 'π' 45 | 46 | val newExpression = 47 | if (openParenthesesNum > closeParenthesesNum && shouldAddCloseParentheses) ")" else "(" 48 | 49 | val newCursorPosition = currentExpression.selection.start + 1 50 | 51 | return currentExpression.copy( 52 | text = addItemToExpression(newExpression, currentExpression), 53 | selection = TextRange(newCursorPosition) 54 | ) 55 | } 56 | 57 | override fun calculateExpression(expression: String): String = try { 58 | expressions.evalToString(expression) 59 | } catch (e: Throwable) { 60 | "" 61 | } 62 | 63 | override fun removeDigit(expression: TextFieldValue): TextFieldValue { 64 | if (expression.text.isBlank()) return expression 65 | 66 | val selection = expression.selection 67 | 68 | // If there is no selection and the cursor is at the beginning of the expression, return the expression as is 69 | if (selection.collapsed && selection.start == 0) return expression 70 | 71 | val removeRange = if (selection.collapsed) { 72 | // If there is no selection, remove the character before the cursor 73 | selection.start - 1 until selection.start 74 | } else { 75 | // If there is a selection, remove the selected text 76 | selection.start until selection.end 77 | } 78 | 79 | val textSelected = expression.text.substring(removeRange) 80 | val textBeforeCursor = expression.text.substring(0, removeRange.first) 81 | 82 | functions.forEach { function -> 83 | if (textSelected in function || textBeforeCursor.endsWith(function)) { 84 | // Remove the function if the cursor is inside it 85 | val functionStartIndex = expression.text.lastIndexOf(function, removeRange.first) 86 | 87 | if (functionStartIndex != -1) { 88 | // Check if the function has parentheses at the end 89 | val hasParentheses = 90 | expression.text.getOrNull(functionStartIndex + function.length) == '(' 91 | val endIndex = 92 | if (hasParentheses) functionStartIndex + function.length + 1 else functionStartIndex + function.length 93 | 94 | return expression.copy( 95 | text = expression.text.removeRange( 96 | startIndex = functionStartIndex, 97 | endIndex = endIndex 98 | ), 99 | selection = TextRange(removeRange.first) 100 | ) 101 | } 102 | } 103 | } 104 | 105 | return expression.copy( 106 | text = expression.text.removeRange(removeRange), 107 | selection = TextRange(removeRange.first) 108 | ) 109 | } 110 | 111 | override fun addValueToExpression(value: String, currentExpression: TextFieldValue): TextFieldValue { 112 | val positionToAdd = currentExpression.selection.start 113 | 114 | return currentExpression.copy( 115 | text = addItemToExpression(value, currentExpression), 116 | selection = TextRange(positionToAdd + value.length) 117 | ) 118 | } 119 | 120 | override fun addActionValueToExpression( 121 | action: ButtonAction, 122 | currentExpression: TextFieldValue 123 | ): TextFieldValue { 124 | val actionValue = action.value 125 | 126 | return currentExpression.copy( 127 | text = addItemToExpression(actionValue, currentExpression), 128 | selection = TextRange(currentExpression.selection.start + actionValue.length) 129 | ) 130 | } 131 | 132 | private fun addItemToExpression( 133 | item: String, 134 | currentExpression: TextFieldValue 135 | ): String { 136 | val positionToAdd = currentExpression.selection.start 137 | 138 | return currentExpression.text.replaceRange(positionToAdd, positionToAdd, item) 139 | } 140 | 141 | override fun changeAngleMode(newMode: model.AngleType) { 142 | expressions.changeAngleFunctions(newMode) 143 | } 144 | } -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/presentation/home/HomeViewModel.kt: -------------------------------------------------------------------------------- 1 | package presentation.home 2 | 3 | import androidx.compose.ui.text.TextRange 4 | import androidx.compose.ui.text.input.TextFieldValue 5 | import androidx.lifecycle.ViewModel 6 | import androidx.lifecycle.viewModelScope 7 | import core.util.ExpressionUtil 8 | import core.ButtonAction 9 | import core.ButtonAction.* 10 | import domain.result.ExpressionResult 11 | import domain.result.ExpressionResultDataSource 12 | import domain.time.DateTimeUtil 13 | import kotlinx.coroutines.flow.* 14 | import kotlinx.coroutines.launch 15 | 16 | class HomeViewModel ( 17 | private val expressionUtil: ExpressionUtil, 18 | // private val expressionResultDataSource: ExpressionResultDataSource 19 | ) : ViewModel() { 20 | private val _uiState = MutableStateFlow(HomeUiState()) 21 | val uiState = combine( 22 | _uiState.asStateFlow(), 23 | emptyFlow() 24 | // expressionResultDataSource.getAllResultsFlow(), 25 | ) { uiState, results -> 26 | // uiState.copy(results = results) 27 | uiState 28 | }.stateIn( 29 | scope = viewModelScope, 30 | started = SharingStarted.WhileSubscribed(), 31 | initialValue = HomeUiState() 32 | ) 33 | 34 | val result = _uiState 35 | .distinctUntilChanged { old, new -> 36 | old.currentExpression.text == new.currentExpression.text && old.angleType == new.angleType 37 | }.map { state -> 38 | expressionUtil.calculateExpression(state.currentExpression.text) 39 | }.stateIn( 40 | scope = viewModelScope, 41 | started = SharingStarted.WhileSubscribed(), 42 | initialValue = "" 43 | ) 44 | 45 | fun onEvent(event: HomeUiEvent) { 46 | when (event) { 47 | is HomeUiEvent.OnButtonActionClick -> processAction(event.action) 48 | is HomeUiEvent.UpdateTextFieldValue -> updateTextFieldValue(event.value) 49 | is HomeUiEvent.InsertIntoExpression -> addValueToExpression(event.value) 50 | is HomeUiEvent.OnChangeMoreActionsClick -> changeMoreActionsState() 51 | } 52 | } 53 | 54 | private fun processAction(action: ButtonAction) { 55 | when (action) { 56 | is ButtonAngle -> changeAngleType() 57 | is ButtonInverse -> changeInverse() 58 | is ButtonEqual -> replaceResultInExpression() 59 | is ButtonClear -> clearExpression() 60 | is ButtonParentheses -> addParentheses() 61 | is ButtonRemove -> removeDigit() 62 | else -> addActionValueToExpression(action) 63 | } 64 | } 65 | 66 | private fun changeAngleType() { 67 | _uiState.update { state -> 68 | val newAngleType = state.angleType.next() 69 | expressionUtil.changeAngleMode(newAngleType) 70 | 71 | state.copy(angleType = newAngleType) 72 | } 73 | } 74 | 75 | private fun changeInverse() { 76 | _uiState.update { state -> 77 | state.copy(isInverse = !state.isInverse) 78 | } 79 | } 80 | 81 | private fun addValueToExpression(value: String) { 82 | _uiState.update { currentState -> 83 | val currentExpression = currentState.currentExpression 84 | val newExpression = expressionUtil.addValueToExpression(value, currentExpression) 85 | 86 | currentState.copy(currentExpression = newExpression) 87 | } 88 | } 89 | 90 | private fun addActionValueToExpression(action: ButtonAction) { 91 | _uiState.update { currentState -> 92 | val currentExpression = currentState.currentExpression 93 | val newExpression = expressionUtil.addActionValueToExpression(action, currentExpression) 94 | 95 | currentState.copy(currentExpression = newExpression) 96 | } 97 | } 98 | 99 | private fun clearExpression() { 100 | _uiState.update { currentState -> 101 | currentState.copy(currentExpression = TextFieldValue()) 102 | } 103 | } 104 | 105 | /** 106 | * Replace the current expression with the result and set the cursor at the end of the expression 107 | */ 108 | private fun replaceResultInExpression() { 109 | _uiState.updateAndGet { currentState -> 110 | val result = expressionUtil.calculateExpression(currentState.currentExpression.text) 111 | 112 | // Save the result in the database 113 | // viewModelScope.launch { 114 | // expressionResultDataSource.insertResult( 115 | // result = ExpressionResult( 116 | // expression = currentState.currentExpression.text, 117 | // result = result, 118 | // createdAt = DateTimeUtil.now() 119 | // ) 120 | // ) 121 | // } 122 | 123 | currentState.copy( 124 | currentExpression = TextFieldValue( 125 | text = result, 126 | // Set the cursor at the end of the expression 127 | selection = TextRange(result.length) 128 | ) 129 | ) 130 | } 131 | } 132 | 133 | private fun removeDigit() { 134 | _uiState.update { currentState -> 135 | val currentExpression = currentState.currentExpression 136 | val newExpression = expressionUtil.removeDigit(currentExpression) 137 | 138 | currentState.copy(currentExpression = newExpression) 139 | } 140 | } 141 | 142 | private fun addParentheses() { 143 | _uiState.update { uiState -> 144 | val currentExpression = uiState.currentExpression 145 | val newExpression = expressionUtil.addParentheses(currentExpression) 146 | 147 | uiState.copy(currentExpression = newExpression) 148 | } 149 | } 150 | 151 | private fun updateTextFieldValue(value: TextFieldValue) { 152 | _uiState.update { state -> 153 | state.copy(currentExpression = value) 154 | } 155 | } 156 | 157 | private fun changeMoreActionsState() { 158 | _uiState.update { state -> 159 | state.copy(moreActionsExpanded = !state.moreActionsExpanded) 160 | } 161 | } 162 | } -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | # 4 | # Copyright 2015 the original author or authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | ############################################################################## 20 | ## 21 | ## Gradle start up script for UN*X 22 | ## 23 | ############################################################################## 24 | 25 | # Attempt to set APP_HOME 26 | # Resolve links: $0 may be a link 27 | PRG="$0" 28 | # Need this for relative symlinks. 29 | while [ -h "$PRG" ] ; do 30 | ls=`ls -ld "$PRG"` 31 | link=`expr "$ls" : '.*-> \(.*\)$'` 32 | if expr "$link" : '/.*' > /dev/null; then 33 | PRG="$link" 34 | else 35 | PRG=`dirname "$PRG"`"/$link" 36 | fi 37 | done 38 | SAVED="`pwd`" 39 | cd "`dirname \"$PRG\"`/" >/dev/null 40 | APP_HOME="`pwd -P`" 41 | cd "$SAVED" >/dev/null 42 | 43 | APP_NAME="Gradle" 44 | APP_BASE_NAME=`basename "$0"` 45 | 46 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 47 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 48 | 49 | # Use the maximum available, or set MAX_FD != -1 to use that value. 50 | MAX_FD="maximum" 51 | 52 | warn () { 53 | echo "$*" 54 | } 55 | 56 | die () { 57 | echo 58 | echo "$*" 59 | echo 60 | exit 1 61 | } 62 | 63 | # OS specific support (must be 'true' or 'false'). 64 | cygwin=false 65 | msys=false 66 | darwin=false 67 | nonstop=false 68 | case "`uname`" in 69 | CYGWIN* ) 70 | cygwin=true 71 | ;; 72 | Darwin* ) 73 | darwin=true 74 | ;; 75 | MINGW* ) 76 | msys=true 77 | ;; 78 | NONSTOP* ) 79 | nonstop=true 80 | ;; 81 | esac 82 | 83 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 84 | 85 | 86 | # Determine the Java command to use to start the JVM. 87 | if [ -n "$JAVA_HOME" ] ; then 88 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 89 | # IBM's JDK on AIX uses strange locations for the executables 90 | JAVACMD="$JAVA_HOME/jre/sh/java" 91 | else 92 | JAVACMD="$JAVA_HOME/bin/java" 93 | fi 94 | if [ ! -x "$JAVACMD" ] ; then 95 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 96 | 97 | Please set the JAVA_HOME variable in your environment to match the 98 | location of your Java installation." 99 | fi 100 | else 101 | JAVACMD="java" 102 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 103 | 104 | Please set the JAVA_HOME variable in your environment to match the 105 | location of your Java installation." 106 | fi 107 | 108 | # Increase the maximum file descriptors if we can. 109 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 110 | MAX_FD_LIMIT=`ulimit -H -n` 111 | if [ $? -eq 0 ] ; then 112 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 113 | MAX_FD="$MAX_FD_LIMIT" 114 | fi 115 | ulimit -n $MAX_FD 116 | if [ $? -ne 0 ] ; then 117 | warn "Could not set maximum file descriptor limit: $MAX_FD" 118 | fi 119 | else 120 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 121 | fi 122 | fi 123 | 124 | # For Darwin, add options to specify how the application appears in the dock 125 | if $darwin; then 126 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 127 | fi 128 | 129 | # For Cygwin or MSYS, switch paths to Windows format before running java 130 | if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then 131 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 132 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 133 | 134 | JAVACMD=`cygpath --unix "$JAVACMD"` 135 | 136 | # We build the pattern for arguments to be converted via cygpath 137 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 138 | SEP="" 139 | for dir in $ROOTDIRSRAW ; do 140 | ROOTDIRS="$ROOTDIRS$SEP$dir" 141 | SEP="|" 142 | done 143 | OURCYGPATTERN="(^($ROOTDIRS))" 144 | # Add a user-defined pattern to the cygpath arguments 145 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 146 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 147 | fi 148 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 149 | i=0 150 | for arg in "$@" ; do 151 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 152 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 153 | 154 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 155 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 156 | else 157 | eval `echo args$i`="\"$arg\"" 158 | fi 159 | i=`expr $i + 1` 160 | done 161 | case $i in 162 | 0) set -- ;; 163 | 1) set -- "$args0" ;; 164 | 2) set -- "$args0" "$args1" ;; 165 | 3) set -- "$args0" "$args1" "$args2" ;; 166 | 4) set -- "$args0" "$args1" "$args2" "$args3" ;; 167 | 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 168 | 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 169 | 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 170 | 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 171 | 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 172 | esac 173 | fi 174 | 175 | # Escape application args 176 | save () { 177 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 178 | echo " " 179 | } 180 | APP_ARGS=`save "$@"` 181 | 182 | # Collect all arguments for the java command, following the shell quoting and substitution rules 183 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 184 | 185 | exec "$JAVACMD" "$@" 186 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/core/evaluator/internal/Parser.kt: -------------------------------------------------------------------------------- 1 | package core.evaluator.internal 2 | 3 | import core.evaluator.internal.TokenType.* 4 | import java.math.BigDecimal 5 | 6 | internal class Parser(private val tokens: List) { 7 | 8 | private var current = 0 9 | 10 | fun parse(): Expr { 11 | val expr = expression() 12 | 13 | if (!isAtEnd()) { 14 | throw ExpressionException("Expected end of expression, found '${peek().lexeme}'") 15 | } 16 | 17 | return expr 18 | } 19 | 20 | private fun expression(): Expr { 21 | return assignment() 22 | } 23 | 24 | private fun assignment(): Expr { 25 | val expr = or() 26 | 27 | if (match(ASSIGN)) { 28 | val value = assignment() 29 | 30 | if (expr is VariableExpr) { 31 | val name = expr.name 32 | 33 | return AssignExpr(name, value) 34 | } else { 35 | throw ExpressionException("Invalid assignment target") 36 | } 37 | } 38 | 39 | return expr 40 | } 41 | 42 | private fun or(): Expr { 43 | var expr = and() 44 | 45 | while (match(BAR_BAR)) { 46 | val operator = previous() 47 | val right = and() 48 | 49 | expr = LogicalExpr(expr, operator, right) 50 | } 51 | 52 | return expr 53 | } 54 | 55 | private fun and(): Expr { 56 | var expr = equality() 57 | 58 | while (match(AMP_AMP)) { 59 | val operator = previous() 60 | val right = equality() 61 | 62 | expr = LogicalExpr(expr, operator, right) 63 | } 64 | 65 | return expr 66 | } 67 | 68 | private fun equality(): Expr { 69 | var left = comparison() 70 | 71 | while (match(EQUAL_EQUAL, NOT_EQUAL)) { 72 | val operator = previous() 73 | val right = comparison() 74 | 75 | left = BinaryExpr(left, operator, right) 76 | } 77 | 78 | return left 79 | } 80 | 81 | private fun comparison(): Expr { 82 | var left = addition() 83 | 84 | while (match(GREATER, GREATER_EQUAL, LESS, LESS_EQUAL)) { 85 | val operator = previous() 86 | val right = addition() 87 | 88 | left = BinaryExpr(left, operator, right) 89 | } 90 | 91 | return left 92 | } 93 | 94 | private fun addition(): Expr { 95 | var left = multiplication() 96 | 97 | while (match(PLUS, MINUS)) { 98 | val operator = previous() 99 | val right = multiplication() 100 | 101 | left = BinaryExpr(left, operator, right) 102 | } 103 | 104 | return left 105 | } 106 | 107 | private fun multiplication(): Expr { 108 | var left = unary() 109 | 110 | while (match(STAR, SLASH, MODULO)) { 111 | val operator = previous() 112 | val right = unary() 113 | 114 | left = BinaryExpr(left, operator, right) 115 | } 116 | 117 | return left 118 | } 119 | 120 | private fun unary(): Expr { 121 | if (match(MINUS)) { 122 | val operator = previous() 123 | val right = unary() 124 | 125 | return UnaryExpr(operator, right) 126 | } 127 | 128 | return factorial() 129 | } 130 | 131 | private fun factorial(): Expr { 132 | val left = exponent() 133 | 134 | if (match(FACTORIAL)) { 135 | val operator = previous() 136 | 137 | return LeftExpr(operator, left) 138 | } 139 | 140 | return left 141 | } 142 | 143 | private fun exponent(): Expr { 144 | var left = call() 145 | 146 | if (match(EXPONENT)) { 147 | val operator = previous() 148 | val right = unary() 149 | 150 | left = BinaryExpr(left, operator, right) 151 | } 152 | 153 | return left 154 | } 155 | 156 | private fun call(): Expr { 157 | if (matchTwo(IDENTIFIER, LEFT_PAREN)) { 158 | val (name, _) = previousTwo() 159 | 160 | val arguments = mutableListOf() 161 | 162 | if (!check(RIGHT_PAREN)) { 163 | do { 164 | arguments += expression() 165 | } while (match(COMMA)) 166 | } 167 | 168 | consume(RIGHT_PAREN, "Expected ')' after function arguments") 169 | 170 | return CallExpr(name.lexeme, arguments) 171 | } 172 | 173 | return primary() 174 | } 175 | 176 | private fun primary(): Expr { 177 | if (match(NUMBER)) { 178 | return LiteralExpr(previous().literal as BigDecimal) 179 | } 180 | 181 | if (match(IDENTIFIER)) { 182 | return VariableExpr(previous()) 183 | } 184 | 185 | if (match(LEFT_PAREN)) { 186 | val expr = expression() 187 | 188 | consume(RIGHT_PAREN, "Expected ')' after '${previous().lexeme}'.") 189 | 190 | return GroupingExpr(expr) 191 | } 192 | 193 | throw ExpressionException("Expected expression after '${previous().lexeme}'.") 194 | } 195 | 196 | private fun match(vararg types: TokenType): Boolean { 197 | for (type in types) { 198 | if (check(type)) { 199 | advance() 200 | 201 | return true 202 | } 203 | } 204 | 205 | return false 206 | } 207 | 208 | private fun matchTwo(first: TokenType, second: TokenType): Boolean { 209 | val start = current 210 | 211 | if (match(first) && match(second)) { 212 | return true 213 | } 214 | 215 | current = start 216 | return false 217 | } 218 | 219 | private fun check(tokenType: TokenType): Boolean { 220 | return if (isAtEnd()) { 221 | false 222 | } else { 223 | peek().type === tokenType 224 | } 225 | } 226 | 227 | private fun consume(type: TokenType, message: String): Token { 228 | if (check(type)) return advance() 229 | 230 | throw ExpressionException(message) 231 | } 232 | 233 | private fun advance(): Token { 234 | if (!isAtEnd()) current++ 235 | 236 | return previous() 237 | } 238 | 239 | private fun isAtEnd() = peek().type == EOF 240 | 241 | private fun peek() = tokens[current] 242 | 243 | private fun previous() = tokens[current - 1] 244 | 245 | private fun previousTwo() = Pair(tokens[current - 2], tokens[current - 1]) 246 | } 247 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/core/ButtonAction.kt: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import androidx.compose.material3.MaterialTheme 4 | import androidx.compose.runtime.Composable 5 | import androidx.compose.runtime.ReadOnlyComposable 6 | import androidx.compose.ui.graphics.Color 7 | 8 | sealed class ButtonAction( 9 | val displayText: String, 10 | val value: String = displayText 11 | ) { 12 | companion object { 13 | private val colorSurface: Color 14 | @Composable 15 | @ReadOnlyComposable 16 | get() = MaterialTheme.colorScheme.surface 17 | 18 | private val colorPrimary: Color 19 | @Composable 20 | @ReadOnlyComposable 21 | get() = MaterialTheme.colorScheme.primary.copy(alpha = 0.25f) 22 | 23 | private val colorPrimaryContainer: Color 24 | @Composable 25 | @ReadOnlyComposable 26 | get() = MaterialTheme.colorScheme.primaryContainer 27 | 28 | private val colorTertiaryContainer: Color 29 | @Composable 30 | @ReadOnlyComposable 31 | get() = MaterialTheme.colorScheme.tertiaryContainer 32 | 33 | @Composable 34 | @ReadOnlyComposable 35 | fun ButtonAction.getColorByButton(): Color = when (this) { 36 | is ButtonParentheses, ButtonPercent, ButtonDivide, ButtonMultiply, ButtonMinus, ButtonPlus -> colorPrimary 37 | is ButtonEqual -> colorPrimaryContainer 38 | is ButtonClear -> colorTertiaryContainer 39 | else -> colorSurface 40 | } 41 | 42 | fun getAllPrimaryButtons(isPortrait: Boolean): List = if (isPortrait) { 43 | listOf( 44 | ButtonClear, 45 | ButtonParentheses, 46 | ButtonPercent, 47 | ButtonDivide, 48 | Button7, 49 | Button8, 50 | Button9, 51 | ButtonMultiply, 52 | Button4, 53 | Button5, 54 | Button6, 55 | ButtonMinus, 56 | Button1, 57 | Button2, 58 | Button3, 59 | ButtonPlus, 60 | Button0, 61 | ButtonDot, 62 | ButtonRemove, 63 | ButtonEqual 64 | ) 65 | } else { 66 | listOf( 67 | // First row 68 | Button7, 69 | Button8, 70 | Button9, 71 | ButtonDivide, 72 | ButtonClear, 73 | // Second row 74 | Button4, 75 | Button5, 76 | Button6, 77 | ButtonMultiply, 78 | ButtonParentheses, 79 | // Third row 80 | Button1, 81 | Button2, 82 | Button3, 83 | ButtonMinus, 84 | ButtonPercent, 85 | // Fourth row 86 | Button0, 87 | ButtonDot, 88 | ButtonRemove, 89 | ButtonPlus, 90 | ButtonEqual 91 | ) 92 | } 93 | 94 | fun getAllSecondaryButtons( 95 | angleType: model.AngleType, 96 | isInverse: Boolean 97 | ): List = listOf( 98 | ButtonSquareRoot(isInverse), 99 | ButtonPI, 100 | ButtonPower, 101 | ButtonFactorial, 102 | ButtonAngle(angleType), 103 | ButtonSin(isInverse), 104 | ButtonCos(isInverse), 105 | ButtonTan(isInverse), 106 | ButtonInverse, 107 | ButtonExp, 108 | ButtonLn, 109 | ButtonLog(isInverse) 110 | ) 111 | } 112 | 113 | interface Invertible { 114 | val isInverted: Boolean 115 | } 116 | 117 | data object Button0 : ButtonAction(displayText = "0") 118 | data object Button1 : ButtonAction(displayText = "1") 119 | data object Button2 : ButtonAction(displayText = "2") 120 | data object Button3 : ButtonAction(displayText = "3") 121 | data object Button4 : ButtonAction(displayText = "4") 122 | data object Button5 : ButtonAction(displayText = "5") 123 | data object Button6 : ButtonAction(displayText = "6") 124 | data object Button7 : ButtonAction(displayText = "7") 125 | data object Button8 : ButtonAction(displayText = "8") 126 | data object Button9 : ButtonAction(displayText = "9") 127 | 128 | data object ButtonDot : ButtonAction(displayText = ".") 129 | data object ButtonEqual : ButtonAction(displayText = "=") 130 | data object ButtonPlus : ButtonAction(displayText = "+") 131 | data object ButtonMinus : ButtonAction(displayText = "-") 132 | data object ButtonMultiply : ButtonAction(displayText = "*") 133 | data object ButtonDivide : ButtonAction(displayText = "/") 134 | data object ButtonPercent : ButtonAction(displayText = "%") 135 | 136 | data object ButtonClear : ButtonAction(displayText = "AC") 137 | data object ButtonRemove : ButtonAction(displayText = "⌫") 138 | data object ButtonParentheses : ButtonAction(displayText = "( )") 139 | 140 | data class ButtonSquareRoot( 141 | override val isInverted: Boolean 142 | ) : ButtonAction( 143 | displayText = if (isInverted) "x²" else "√", 144 | value = if (isInverted) "^2" else "√" 145 | ), Invertible 146 | data object ButtonPI : ButtonAction(displayText = "π") 147 | data object ButtonPower : ButtonAction(displayText = "^") 148 | data object ButtonFactorial : ButtonAction(displayText = "!") 149 | 150 | data class ButtonAngle( 151 | val angleType: model.AngleType 152 | ) : ButtonAction(displayText = angleType.next().name) 153 | 154 | data object ButtonInverse : ButtonAction(displayText = "INV") 155 | 156 | data class ButtonSin( 157 | override val isInverted: Boolean, 158 | ) : ButtonAction( 159 | displayText = if (isInverted) "sin⁻¹" else "sin", 160 | value = if (isInverted) "asin(" else "sin(" 161 | ), Invertible 162 | 163 | data class ButtonCos( 164 | override val isInverted: Boolean = false 165 | ) : ButtonAction( 166 | displayText = if (isInverted) "cos⁻¹" else "cos", 167 | value = if (isInverted) "acos(" else "cos(" 168 | ), Invertible 169 | 170 | data class ButtonTan( 171 | override val isInverted: Boolean = false 172 | ) : ButtonAction( 173 | displayText = if (isInverted) "tan⁻¹" else "tan", 174 | value = if (isInverted) "atan(" else "tan(" 175 | ), Invertible 176 | 177 | data class ButtonLog( 178 | override val isInverted: Boolean 179 | ) : ButtonAction( 180 | displayText = if (isInverted) "10^" else "log", 181 | value = if (isInverted) "10^" else "log(" 182 | ), Invertible 183 | 184 | data object ButtonLn : ButtonAction( 185 | displayText = "ln", 186 | value = "ln(" 187 | ) 188 | 189 | data object ButtonExp : ButtonAction(displayText = "e") 190 | } 191 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/presentation/home/HomeScreen.kt: -------------------------------------------------------------------------------- 1 | package presentation.home 2 | 3 | import androidx.compose.foundation.layout.* 4 | import androidx.compose.foundation.lazy.LazyColumn 5 | import androidx.compose.foundation.lazy.items 6 | import androidx.compose.foundation.shape.RoundedCornerShape 7 | import androidx.compose.material.icons.Icons 8 | import androidx.compose.material.icons.rounded.MoreVert 9 | import androidx.compose.material3.* 10 | import androidx.compose.material3.windowsizeclass.WindowSizeClass 11 | import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass 12 | import androidx.compose.runtime.* 13 | import androidx.compose.ui.Modifier 14 | import androidx.compose.ui.unit.dp 15 | import androidx.constraintlayout.compose.* 16 | import presentation.components.button.secondary.SecondaryButtonGrid 17 | import presentation.components.expression.ExpressionContent 18 | import core.presentation.theme.spacing 19 | import org.koin.compose.viewmodel.koinViewModel 20 | import org.koin.core.annotation.KoinExperimentalAPI 21 | import presentation.components.button.primary.ButtonGrid 22 | import presentation.home.components.HistoryList 23 | 24 | @Composable 25 | @OptIn(KoinExperimentalAPI::class) 26 | internal fun HomeScreen( 27 | windowSizeClass: WindowSizeClass, 28 | homeViewModel: HomeViewModel = koinViewModel() 29 | ) { 30 | val uiState by homeViewModel.uiState.collectAsState() 31 | val result by homeViewModel.result.collectAsState() 32 | 33 | HomeScreenImpl( 34 | uiState = uiState, 35 | result = result, 36 | windowSizeClass = windowSizeClass, 37 | onEvent = homeViewModel::onEvent 38 | ) 39 | } 40 | 41 | @Composable 42 | fun HomeScreenImpl( 43 | uiState: HomeUiState, 44 | result: String, 45 | windowSizeClass: WindowSizeClass, 46 | onEvent: (event: HomeUiEvent) -> Unit 47 | ) { 48 | Surface { 49 | HomeContent( 50 | uiState = uiState, 51 | result = result, 52 | windowSizeClass = windowSizeClass, 53 | onEvent = onEvent 54 | ) 55 | } 56 | } 57 | 58 | @Composable 59 | private fun HomeContent( 60 | uiState: HomeUiState, 61 | result: String, 62 | windowSizeClass: WindowSizeClass, 63 | onEvent: (event: HomeUiEvent) -> Unit 64 | ) { 65 | val spaceMedium = MaterialTheme.spacing.medium 66 | 67 | val showLeftExpressionHistory = windowSizeClass.widthSizeClass == WindowWidthSizeClass.Expanded 68 | val verticalContent = windowSizeClass.widthSizeClass == WindowWidthSizeClass.Compact 69 | 70 | ConstraintLayout( 71 | modifier = Modifier.fillMaxSize() 72 | ) { 73 | val (expressionContent, primaryButtons, secondaryButtons, historyComponent) = createRefs() 74 | 75 | if (showLeftExpressionHistory) { 76 | HistoryList( 77 | modifier = Modifier.constrainAs(historyComponent) { 78 | start.linkTo(parent.start, spaceMedium) 79 | top.linkTo(parent.top, spaceMedium) 80 | bottom.linkTo(parent.bottom, spaceMedium) 81 | 82 | height = Dimension.fillToConstraints 83 | width = Dimension.value(300.dp) 84 | }, 85 | results = uiState.results, 86 | insertIntoExpression = { expression -> 87 | onEvent(HomeUiEvent.InsertIntoExpression(expression)) 88 | } 89 | ) 90 | } 91 | 92 | ExpressionContent( 93 | modifier = Modifier.constrainAs(expressionContent) { 94 | if (showLeftExpressionHistory) { 95 | start.linkTo(historyComponent.end, spaceMedium) 96 | } else { 97 | start.linkTo(parent.start, if (verticalContent) 0.dp else spaceMedium) 98 | } 99 | 100 | end.linkTo(parent.end, if (verticalContent) 0.dp else spaceMedium) 101 | top.linkTo(parent.top, if (verticalContent) 0.dp else spaceMedium) 102 | bottom.linkTo(secondaryButtons.top, spaceMedium) 103 | 104 | width = Dimension.fillToConstraints 105 | height = Dimension.fillToConstraints 106 | }, 107 | currentExpression = uiState.currentExpression, 108 | result = result, 109 | angleType = uiState.angleType, 110 | updateTextFieldValue = { value -> 111 | onEvent(HomeUiEvent.UpdateTextFieldValue(value)) 112 | }, 113 | shape = if (verticalContent) RoundedCornerShape( 114 | bottomStart = 20.dp, 115 | bottomEnd = 20.dp 116 | ) else RoundedCornerShape(20.dp), 117 | ) 118 | 119 | ButtonGrid( 120 | modifier = Modifier.constrainAs(primaryButtons) { 121 | if (verticalContent) { 122 | top.linkTo(secondaryButtons.bottom) 123 | start.linkTo(parent.start, spaceMedium) 124 | } else { 125 | top.linkTo(expressionContent.bottom, spaceMedium) 126 | start.linkTo(secondaryButtons.end, spaceMedium) 127 | } 128 | 129 | end.linkTo(parent.end, spaceMedium) 130 | bottom.linkTo(parent.bottom, spaceMedium) 131 | 132 | width = Dimension.fillToConstraints 133 | height = Dimension.fillToConstraints 134 | }, 135 | isPortrait = verticalContent, 136 | onActionClick = { action -> 137 | onEvent(HomeUiEvent.OnButtonActionClick(action)) 138 | }, 139 | ) 140 | 141 | SecondaryButtonGrid( 142 | modifier = Modifier.constrainAs(secondaryButtons) { 143 | if (showLeftExpressionHistory && !verticalContent) { 144 | start.linkTo(historyComponent.end, spaceMedium) 145 | } else { 146 | start.linkTo(parent.start, spaceMedium) 147 | } 148 | 149 | top.linkTo(expressionContent.bottom) 150 | 151 | if (verticalContent) { 152 | end.linkTo(parent.end, spaceMedium) 153 | bottom.linkTo(primaryButtons.top, spaceMedium) 154 | width = Dimension.fillToConstraints 155 | height = Dimension.wrapContent 156 | } else { 157 | bottom.linkTo(parent.bottom, spaceMedium) 158 | width = Dimension.wrapContent 159 | height = Dimension.fillToConstraints 160 | } 161 | }, 162 | isPortrait = verticalContent, 163 | buttonGridExpanded = uiState.moreActionsExpanded, 164 | angleType = uiState.angleType, 165 | isInverse = uiState.isInverse, 166 | onActionClick = { action -> 167 | onEvent(HomeUiEvent.OnButtonActionClick(action)) 168 | }, 169 | onMoreActionsClick = { onEvent(HomeUiEvent.OnChangeMoreActionsClick) } 170 | ) 171 | } 172 | } 173 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/presentation/components/expression/ExpressionContent.kt: -------------------------------------------------------------------------------- 1 | package presentation.components.expression 2 | 3 | import androidx.compose.foundation.horizontalScroll 4 | import androidx.compose.foundation.layout.* 5 | import androidx.compose.foundation.rememberScrollState 6 | import androidx.compose.foundation.shape.RoundedCornerShape 7 | import androidx.compose.foundation.text.BasicTextField 8 | import androidx.compose.foundation.text.KeyboardOptions 9 | import androidx.compose.foundation.text.selection.SelectionContainer 10 | import androidx.compose.material.icons.Icons 11 | import androidx.compose.material.icons.rounded.MoreVert 12 | import androidx.compose.material3.* 13 | import androidx.compose.runtime.* 14 | import androidx.compose.ui.Alignment 15 | import androidx.compose.ui.Modifier 16 | import androidx.compose.ui.graphics.SolidColor 17 | import androidx.compose.ui.platform.LocalDensity 18 | import androidx.compose.ui.platform.LocalTextInputService 19 | import androidx.compose.ui.text.ExperimentalTextApi 20 | import androidx.compose.ui.text.ParagraphIntrinsics 21 | import androidx.compose.ui.text.TextStyle 22 | import androidx.compose.ui.text.font.FontWeight 23 | import androidx.compose.ui.text.input.KeyboardType 24 | import androidx.compose.ui.text.input.TextFieldValue 25 | import androidx.compose.ui.text.style.TextAlign 26 | import androidx.compose.ui.text.style.TextOverflow 27 | import androidx.compose.ui.unit.TextUnit 28 | import androidx.compose.ui.unit.dp 29 | import androidx.compose.ui.unit.sp 30 | import core.util.ExpressionUtilImpl 31 | import core.presentation.theme.spacing 32 | import presentation.util.text.createFontFamilyResolver 33 | import model.AngleType 34 | 35 | @Composable 36 | fun ExpressionContent( 37 | modifier: Modifier = Modifier, 38 | currentExpression: TextFieldValue, 39 | result: String, 40 | angleType: AngleType, 41 | shape: RoundedCornerShape = RoundedCornerShape(20.dp), 42 | updateTextFieldValue: (value: TextFieldValue) -> Unit 43 | ) { 44 | val spaceMedium = MaterialTheme.spacing.medium 45 | 46 | // Show angle text if the expression contains angle functions 47 | val showAngleText = remember(currentExpression.text) { 48 | derivedStateOf { 49 | ExpressionUtilImpl.angleFunctions.any { currentExpression.text.contains(it) } 50 | } 51 | } 52 | 53 | Surface( 54 | tonalElevation = 8.dp, 55 | modifier = modifier, 56 | shape = shape 57 | ) { 58 | Column( 59 | horizontalAlignment = Alignment.CenterHorizontally, 60 | verticalArrangement = Arrangement.SpaceBetween, 61 | modifier = Modifier.padding(spaceMedium) 62 | ) { 63 | Row( 64 | verticalAlignment = Alignment.CenterVertically, 65 | modifier = Modifier.fillMaxWidth() 66 | ) { 67 | if (showAngleText.value) { 68 | Text( 69 | text = angleType.name, 70 | style = MaterialTheme.typography.bodyLarge, 71 | textAlign = TextAlign.Start, 72 | color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) 73 | ) 74 | } 75 | Spacer(modifier = Modifier.weight(1f)) 76 | IconButton(onClick = { /*TODO*/ }) { 77 | Icon( 78 | imageVector = Icons.Rounded.MoreVert, 79 | contentDescription = "More options", 80 | ) 81 | } 82 | } 83 | 84 | CompositionLocalProvider( 85 | // Prevents opening the keyboard when the text field is tapped 86 | value = LocalTextInputService provides null 87 | ) { 88 | AutoSizeTextField( 89 | inputValue = currentExpression, 90 | inputValueChanged = updateTextFieldValue, 91 | modifier = Modifier.fillMaxWidth() 92 | ) 93 | } 94 | 95 | SelectionContainer { 96 | Text( 97 | modifier = Modifier 98 | .fillMaxWidth() 99 | .horizontalScroll(rememberScrollState()), 100 | text = result, 101 | style = MaterialTheme.typography.displaySmall, 102 | textAlign = TextAlign.End, 103 | maxLines = 1, 104 | overflow = TextOverflow.Ellipsis, 105 | color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f) 106 | ) 107 | } 108 | } 109 | } 110 | } 111 | 112 | private const val TEXT_SCALE_REDUCTION_INTERVAL = 0.9f 113 | 114 | // Original: https://github.com/banmarkovic/AutoSizeTextField 115 | @Composable 116 | @OptIn(ExperimentalTextApi::class) 117 | private fun AutoSizeTextField( 118 | modifier: Modifier = Modifier, 119 | inputValue: TextFieldValue, 120 | maxFontSize: TextUnit = 100.sp, 121 | minFontSize: TextUnit = 30.sp, 122 | inputValueChanged: (TextFieldValue) -> Unit, 123 | ) { 124 | BoxWithConstraints(modifier = modifier.fillMaxWidth()) { 125 | var shrunkFontSize = maxFontSize 126 | 127 | val calculateIntrinsics = @Composable { 128 | ParagraphIntrinsics( 129 | text = inputValue.text, 130 | style = TextStyle( 131 | fontSize = shrunkFontSize, 132 | fontWeight = FontWeight.SemiBold, 133 | textAlign = TextAlign.Center 134 | ), 135 | density = LocalDensity.current, 136 | fontFamilyResolver = createFontFamilyResolver() 137 | ) 138 | } 139 | 140 | var intrinsics = calculateIntrinsics() 141 | with(LocalDensity.current) { 142 | // TextField and OutlinedText field have default horizontal padding of 16.dp 143 | val textFieldDefaultHorizontalPadding = 16.dp.toPx() 144 | val maxInputWidth = maxWidth.toPx() - 2 * textFieldDefaultHorizontalPadding 145 | 146 | while (intrinsics.maxIntrinsicWidth > maxInputWidth && shrunkFontSize > minFontSize) { 147 | shrunkFontSize *= TEXT_SCALE_REDUCTION_INTERVAL 148 | if (shrunkFontSize < minFontSize) { 149 | shrunkFontSize = minFontSize 150 | break 151 | } 152 | intrinsics = calculateIntrinsics() 153 | } 154 | } 155 | 156 | BasicTextField( 157 | modifier = Modifier 158 | .fillMaxWidth() 159 | .horizontalScroll( 160 | state = rememberScrollState(), 161 | reverseScrolling = true, 162 | ), 163 | value = inputValue, 164 | onValueChange = { inputValueChanged(it) }, 165 | textStyle = TextStyle( 166 | fontSize = shrunkFontSize, 167 | textAlign = TextAlign.End, 168 | color = MaterialTheme.colorScheme.onSurface 169 | ), 170 | singleLine = true, 171 | cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), 172 | readOnly = false, 173 | keyboardOptions = KeyboardOptions( 174 | keyboardType = KeyboardType.Number, 175 | ), 176 | ) 177 | } 178 | } 179 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/core/evaluator/Expressions.kt: -------------------------------------------------------------------------------- 1 | package core.evaluator 2 | 3 | import core.evaluator.internal.* 4 | import core.evaluator.internal.Evaluator 5 | import core.evaluator.internal.Expr 6 | import core.evaluator.internal.Function 7 | import core.evaluator.internal.Parser 8 | import core.evaluator.internal.Token 9 | import model.AngleType 10 | import java.math.BigDecimal 11 | import java.math.MathContext 12 | import java.math.RoundingMode 13 | import kotlin.math.acos 14 | import kotlin.math.asin 15 | import kotlin.math.atan 16 | import kotlin.math.cos 17 | import kotlin.math.log 18 | import kotlin.math.log10 19 | import kotlin.math.sin 20 | import kotlin.math.sqrt 21 | import kotlin.math.tan 22 | 23 | internal class Expressions( 24 | private val evaluator: Evaluator 25 | ) { 26 | companion object { 27 | val DEFAULT_ANGLE_TYPE = AngleType.DEG 28 | } 29 | 30 | init { 31 | define("π", Math.PI) 32 | define("e", Math.E) 33 | 34 | evaluator.addFunction("ln", object : Function() { 35 | override fun call(arguments: List): BigDecimal { 36 | if (arguments.size != 1) throw ExpressionException("ln requires one argument") 37 | 38 | return log(arguments.first().toDouble(), Math.E).toBigDecimal() 39 | } 40 | }) 41 | 42 | evaluator.addFunction("log", object : Function() { 43 | override fun call(arguments: List): BigDecimal { 44 | if (arguments.size != 1) throw ExpressionException("log requires one argument") 45 | 46 | return log10(arguments.first().toDouble()).toBigDecimal() 47 | } 48 | }) 49 | 50 | evaluator.addFunction("√", object : Function() { 51 | override fun call(arguments: List): BigDecimal { 52 | if (arguments.size != 1) throw ExpressionException("square root requires one argument") 53 | 54 | return sqrt(arguments.first().toDouble()).toBigDecimal() 55 | } 56 | }) 57 | 58 | changeAngleFunctions(DEFAULT_ANGLE_TYPE) 59 | } 60 | 61 | fun changeAngleFunctions(angleType: AngleType) { 62 | evaluator.replaceFunction("sin", object : Function() { 63 | override fun call(arguments: List): BigDecimal { 64 | if (arguments.size != 1) throw ExpressionException("sin requires one argument") 65 | 66 | if (angleType == AngleType.DEG) { 67 | return sin( 68 | Math.toRadians( 69 | arguments.first().toDouble() 70 | ) 71 | ).toBigDecimal(evaluator.mathContext) 72 | } 73 | 74 | return sin(arguments.first().toDouble()).toBigDecimal(evaluator.mathContext) 75 | } 76 | }) 77 | 78 | evaluator.replaceFunction("asin", object : Function() { 79 | override fun call(arguments: List): BigDecimal { 80 | if (arguments.size != 1) throw ExpressionException("asin requires one argument") 81 | 82 | if (angleType == AngleType.DEG) { 83 | return Math.toDegrees(asin(arguments.first().toDouble())).toBigDecimal() 84 | } 85 | 86 | return asin(arguments.first().toDouble()).toBigDecimal() 87 | } 88 | }) 89 | 90 | evaluator.replaceFunction("cos", object : Function() { 91 | override fun call(arguments: List): BigDecimal { 92 | if (arguments.size != 1) throw ExpressionException("cos requires one argument") 93 | 94 | if (angleType == AngleType.DEG) { 95 | return cos(Math.toRadians(arguments.first().toDouble())).toBigDecimal() 96 | } 97 | 98 | return cos(arguments.first().toDouble()).toBigDecimal() 99 | } 100 | }) 101 | 102 | evaluator.replaceFunction("acos", object : Function() { 103 | override fun call(arguments: List): BigDecimal { 104 | if (arguments.size != 1) throw ExpressionException("acos requires one argument") 105 | 106 | if (angleType == AngleType.DEG) { 107 | return Math.toDegrees(acos(arguments.first().toDouble())).toBigDecimal() 108 | } 109 | 110 | return acos(arguments.first().toDouble()).toBigDecimal() 111 | } 112 | }) 113 | 114 | evaluator.replaceFunction("tan", object : Function() { 115 | override fun call(arguments: List): BigDecimal { 116 | if (arguments.size != 1) throw ExpressionException("tan requires one argument") 117 | 118 | if (angleType == AngleType.DEG) { 119 | return tan(Math.toRadians(arguments.first().toDouble())).toBigDecimal() 120 | } 121 | 122 | return tan(arguments.first().toDouble()).toBigDecimal() 123 | } 124 | }) 125 | 126 | evaluator.replaceFunction("atan", object : Function() { 127 | override fun call(arguments: List): BigDecimal { 128 | if (arguments.size != 1) throw ExpressionException("atan requires one argument") 129 | 130 | if (angleType == AngleType.DEG) { 131 | return Math.toDegrees(atan(arguments.first().toDouble())).toBigDecimal() 132 | } 133 | 134 | return atan(arguments.first().toDouble()).toBigDecimal() 135 | } 136 | }) 137 | } 138 | 139 | val precision: Int 140 | get() = evaluator.mathContext.precision 141 | 142 | val roundingMode: RoundingMode 143 | get() = evaluator.mathContext.roundingMode 144 | 145 | fun setPrecision(precision: Int): Expressions { 146 | evaluator.mathContext = MathContext(precision, roundingMode) 147 | 148 | return this 149 | } 150 | 151 | fun setRoundingMode(roundingMode: RoundingMode): Expressions { 152 | evaluator.mathContext = MathContext(precision, roundingMode) 153 | 154 | return this 155 | } 156 | 157 | fun define(name: String, value: Long): Expressions { 158 | define(name, value.toString()) 159 | 160 | return this 161 | } 162 | 163 | fun define(name: String, value: Double): Expressions { 164 | define(name, value.toString()) 165 | return this 166 | } 167 | 168 | fun define(name: String, value: BigDecimal): Expressions { 169 | define(name, value.toPlainString()) 170 | 171 | return this 172 | } 173 | 174 | fun define(name: String, expression: String): Expressions { 175 | val expr = parse(expression) 176 | evaluator.define(name, expr) 177 | 178 | return this 179 | } 180 | 181 | fun addFunction(name: String, function: Function): Expressions { 182 | evaluator.addFunction(name, function) 183 | 184 | return this 185 | } 186 | 187 | fun addFunction(name: String, func: (List) -> BigDecimal): Expressions { 188 | evaluator.addFunction(name, object : Function() { 189 | override fun call(arguments: List): BigDecimal { 190 | return func(arguments) 191 | } 192 | 193 | }) 194 | 195 | return this 196 | } 197 | 198 | fun eval(expression: String): BigDecimal { 199 | return evaluator.eval(parse(expression)) 200 | } 201 | 202 | /** 203 | * eval an expression then round it with {@link Evaluator#mathContext} and call toEngineeringString
204 | * if error will return message from Throwable 205 | * @param expression String 206 | * @return String 207 | */ 208 | fun evalToString(expression: String): String { 209 | return try { 210 | evaluator 211 | .eval(parse(expression)) 212 | .round(evaluator.mathContext) 213 | .stripTrailingZeros() 214 | .toPlainString() 215 | } catch (e: Throwable) { 216 | // e.cause?.message ?: e.message ?: "unknown error" 217 | "" 218 | } 219 | } 220 | 221 | private fun parse(expression: String): Expr { 222 | return parse(scan(expression)) 223 | } 224 | 225 | private fun parse(tokens: List): Expr { 226 | return Parser(tokens).parse() 227 | } 228 | 229 | private fun scan(expression: String): List { 230 | return Scanner(expression, evaluator.mathContext).scanTokens() 231 | } 232 | } 233 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/presentation/components/button/secondary/SecondaryGrid.kt: -------------------------------------------------------------------------------- 1 | package presentation.components.button.secondary 2 | 3 | import androidx.compose.animation.AnimatedVisibility 4 | import androidx.compose.animation.core.animateFloatAsState 5 | import androidx.compose.foundation.layout.* 6 | import androidx.compose.foundation.lazy.grid.GridCells 7 | import androidx.compose.foundation.lazy.grid.LazyHorizontalGrid 8 | import androidx.compose.foundation.lazy.grid.LazyVerticalGrid 9 | import androidx.compose.foundation.lazy.grid.items 10 | import androidx.compose.foundation.shape.CircleShape 11 | import androidx.compose.material.icons.Icons 12 | import androidx.compose.material.icons.rounded.ArrowDropDown 13 | import androidx.compose.material3.* 14 | import androidx.compose.runtime.Composable 15 | import androidx.compose.runtime.remember 16 | import androidx.compose.ui.Alignment 17 | import androidx.compose.ui.Modifier 18 | import androidx.compose.ui.graphics.graphicsLayer 19 | import androidx.compose.ui.unit.dp 20 | import core.ButtonAction 21 | import core.presentation.theme.spacing 22 | import model.AngleType 23 | 24 | @Composable 25 | internal fun SecondaryButtonGrid( 26 | modifier: Modifier = Modifier, 27 | isPortrait: Boolean, 28 | buttonGridExpanded: Boolean, 29 | angleType: AngleType, 30 | isInverse: Boolean, 31 | onActionClick: (action: ButtonAction) -> Unit, 32 | onMoreActionsClick: () -> Unit 33 | ) { 34 | val actions = remember(angleType, isInverse) { 35 | ButtonAction.getAllSecondaryButtons( 36 | angleType = angleType, 37 | isInverse = isInverse 38 | ) 39 | } 40 | 41 | SecondaryButtonGrid( 42 | modifier = modifier, 43 | isPortrait = isPortrait, 44 | actions = actions, 45 | buttonGridExpanded = buttonGridExpanded, 46 | onActionClick = onActionClick, 47 | onMoreActionsClick = onMoreActionsClick 48 | ) 49 | } 50 | 51 | @Composable 52 | private fun SecondaryButtonGrid( 53 | modifier: Modifier = Modifier, 54 | isPortrait: Boolean, 55 | actions: List, 56 | buttonGridExpanded: Boolean, 57 | onActionClick: (action: ButtonAction) -> Unit, 58 | onMoreActionsClick: () -> Unit 59 | ) { 60 | val topFixedActions = remember(actions) { actions.take(4) } 61 | val gridActions = remember(actions) { actions.drop(4) } 62 | 63 | if (isPortrait) { 64 | VerticalItemsGrid( 65 | modifier = modifier, 66 | topFixedActions = topFixedActions, 67 | gridActions = gridActions, 68 | buttonGridExpanded = buttonGridExpanded, 69 | onActionClick = onActionClick, 70 | onMoreActionsClick = onMoreActionsClick 71 | ) 72 | } else { 73 | HorizontalItemsGrid( 74 | modifier = modifier, 75 | topFixedActions = topFixedActions, 76 | gridActions = gridActions, 77 | buttonGridExpanded = buttonGridExpanded, 78 | onActionClick = onActionClick, 79 | onMoreActionsClick = onMoreActionsClick 80 | ) 81 | } 82 | } 83 | 84 | @Composable 85 | private fun VerticalItemsGrid( 86 | modifier: Modifier = Modifier, 87 | topFixedActions: List, 88 | gridActions: List, 89 | buttonGridExpanded: Boolean, 90 | onActionClick: (action: ButtonAction) -> Unit, 91 | onMoreActionsClick: () -> Unit 92 | ) { 93 | Column(modifier = modifier) { 94 | VerticalFixedItems( 95 | modifier = Modifier.fillMaxWidth(), 96 | actions = topFixedActions, 97 | buttonGridExpanded = buttonGridExpanded, 98 | onActionClick = onActionClick, 99 | onMoreActionsClick = onMoreActionsClick 100 | ) 101 | 102 | AnimatedVisibility(visible = buttonGridExpanded) { 103 | LazyVerticalGrid( 104 | modifier = Modifier.fillMaxWidth(1f).padding(end = VERTICAL_GRID_RIGHT_PADDING), 105 | columns = GridCells.Fixed(count = 4), 106 | ) { 107 | items(items = gridActions) { action -> 108 | SecondaryButtonComponent( 109 | buttonAction = action, 110 | modifier = Modifier.fillMaxWidth().height(VERTICAL_ITEM_HEIGHT), 111 | onClick = { onActionClick(action) } 112 | ) 113 | } 114 | } 115 | } 116 | } 117 | } 118 | 119 | @Composable 120 | private fun VerticalFixedItems( 121 | modifier: Modifier = Modifier, 122 | actions: List, 123 | buttonGridExpanded: Boolean, 124 | onActionClick: (action: ButtonAction) -> Unit, 125 | onMoreActionsClick: () -> Unit 126 | ) { 127 | check(actions.size == 4) { "TopFixedItems requires exactly 4 actions" } 128 | 129 | Row( 130 | verticalAlignment = Alignment.CenterVertically, 131 | modifier = modifier 132 | ) { 133 | actions.forEach { action -> 134 | SecondaryButtonComponent( 135 | buttonAction = action, 136 | modifier = Modifier.weight(1f).height(VERTICAL_ITEM_HEIGHT), 137 | onClick = { onActionClick(action) } 138 | ) 139 | } 140 | 141 | MoreSecondaryActionsItem( 142 | buttonGridExpanded = buttonGridExpanded, 143 | verticalContent = false, 144 | onClick = onMoreActionsClick 145 | ) 146 | } 147 | } 148 | 149 | @Composable 150 | private fun HorizontalItemsGrid( 151 | modifier: Modifier = Modifier, 152 | topFixedActions: List, 153 | gridActions: List, 154 | buttonGridExpanded: Boolean, 155 | onActionClick: (action: ButtonAction) -> Unit, 156 | onMoreActionsClick: () -> Unit 157 | ) { 158 | val spaceSmall = MaterialTheme.spacing.small 159 | 160 | Row( 161 | modifier = modifier, 162 | verticalAlignment = Alignment.CenterVertically, 163 | horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.medium) 164 | ) { 165 | Surface( 166 | modifier = Modifier.fillMaxHeight().width(HORIZONTAL_ITEM_WIDTH), 167 | shape = MaterialTheme.shapes.extraLarge, 168 | tonalElevation = 4.dp 169 | ) { 170 | HorizontalFixedItems( 171 | modifier = Modifier.fillMaxHeight(), 172 | actions = topFixedActions, 173 | buttonGridExpanded = buttonGridExpanded, 174 | onActionClick = onActionClick, 175 | onMoreActionsClick = onMoreActionsClick 176 | ) 177 | } 178 | 179 | AnimatedVisibility(visible = buttonGridExpanded) { 180 | LazyHorizontalGrid( 181 | modifier = Modifier.fillMaxHeight(), 182 | rows = GridCells.Fixed(count = 4), 183 | verticalArrangement = Arrangement.spacedBy(spaceSmall), 184 | horizontalArrangement = Arrangement.spacedBy(spaceSmall), 185 | ) { 186 | items(items = gridActions) { action -> 187 | SecondaryButtonComponent( 188 | buttonAction = action, 189 | modifier = Modifier.fillMaxHeight().width(HORIZONTAL_ITEM_WIDTH), 190 | onClick = { onActionClick(action) } 191 | ) 192 | } 193 | } 194 | } 195 | } 196 | } 197 | 198 | @Composable 199 | private fun HorizontalFixedItems( 200 | modifier: Modifier = Modifier, 201 | actions: List, 202 | buttonGridExpanded: Boolean, 203 | onActionClick: (action: ButtonAction) -> Unit, 204 | onMoreActionsClick: () -> Unit 205 | ) { 206 | check(actions.size == 4) { "TopFixedItems requires exactly 4 actions" } 207 | 208 | Column( 209 | horizontalAlignment = Alignment.CenterHorizontally, 210 | modifier = modifier 211 | ) { 212 | actions.forEach { action -> 213 | SecondaryButtonComponent( 214 | buttonAction = action, 215 | modifier = Modifier.weight(1f).fillMaxWidth(), 216 | onClick = { onActionClick(action) } 217 | ) 218 | } 219 | 220 | MoreSecondaryActionsItem( 221 | buttonGridExpanded = buttonGridExpanded, 222 | verticalContent = true, 223 | onClick = onMoreActionsClick 224 | ) 225 | } 226 | } 227 | 228 | @Composable 229 | internal fun MoreSecondaryActionsItem( 230 | modifier: Modifier = Modifier, 231 | buttonGridExpanded: Boolean, 232 | verticalContent: Boolean, 233 | onClick: () -> Unit 234 | ) { 235 | val rotation = when { 236 | buttonGridExpanded && verticalContent -> VERTICAL_EXPANDED_ROTATION 237 | buttonGridExpanded && !verticalContent -> HORIZONTAL_EXPANDED_ROTATION 238 | !buttonGridExpanded && !verticalContent -> HORIZONTAL_NORMAL_ROTATION 239 | else -> VERTICAL_NORMAL_ROTATION 240 | } 241 | 242 | val rotationAnimated = animateFloatAsState( 243 | targetValue = rotation, 244 | label = "More Actions Rotation" 245 | ) 246 | 247 | Surface( 248 | modifier = modifier, 249 | shape = CircleShape, 250 | tonalElevation = 8.dp, 251 | onClick = onClick 252 | ) { 253 | Icon( 254 | imageVector = Icons.Rounded.ArrowDropDown, 255 | contentDescription = "More Actions", 256 | modifier = Modifier 257 | .padding(MaterialTheme.spacing.extraSmall) 258 | .graphicsLayer { 259 | rotationZ = rotationAnimated.value 260 | } 261 | ) 262 | } 263 | } 264 | 265 | private val VERTICAL_ITEM_HEIGHT = 48.dp 266 | 267 | /** 268 | * Padding for the right side of the vertical grid to align with the top fixed items 269 | */ 270 | private val VERTICAL_GRID_RIGHT_PADDING = 48.dp 271 | private val HORIZONTAL_ITEM_WIDTH = 64.dp 272 | 273 | private const val VERTICAL_NORMAL_ROTATION = 270f 274 | private const val VERTICAL_EXPANDED_ROTATION = 90f 275 | private const val HORIZONTAL_NORMAL_ROTATION = 0f 276 | private const val HORIZONTAL_EXPANDED_ROTATION = 180f 277 | --------------------------------------------------------------------------------