├── .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 |
5 |
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 | [](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 |
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 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
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 |
5 |
24 |
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 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
--------------------------------------------------------------------------------
/.idea/misc.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
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 |
--------------------------------------------------------------------------------