From d4db1031000a163ae5dc98a99ff571a2bd36e5a2 Mon Sep 17 00:00:00 2001 From: rodri2482 Date: Mon, 2 Nov 2026 15:36:18 +0100 Subject: [PATCH 1/4] Fix critical bugs, harden security, add persistence + tests + CI Security: - Remove unused network/Firebase deps (retrofit, okhttp, moshi, firebase-ai, appcheck recaptcha+debug) to cut attack surface - Enable R8 shrinking for releasable code hiding - Gitignore upload keystores and keystore properties - Add SECURITY.md with vulnerability-reporting policy Bugs: - Locale-independent formatting (Locale.US) so displays and memory+parsing never break on comma-decimal devices (DE, TR, etc.) - Loan: round total months, reject 0 months (was producing Infinity payment) - Overflow reports 'Result too large' instead of 'Cannot divide by zero' - Cap parenthesis nesting at 150 to prevent StackOverflowError crashes - History capped at 100 entries (was unbounded) - Settings, theme, memory and DEG/RAD mode now persisted (SharedPreferences) - Calculation history persisted via Room, survives process death - Accessibility: CalcButton exposes Role.Button + contentDescription Infrastructure: - Unit tests for the expression evaluator (errors, precedence, locale, trig) - GitHub Actions CI: build + Robolectric unit tests - README, MIT LICENSE --- .github/workflows/ci.yml | 41 ++++++ .gitignore | 7 + LICENSE | 21 +++ README.md | 59 ++++++++ SECURITY.md | 28 ++++ app/build.gradle.kts | 42 ++---- .../main/java/com/example/data/HistoryDao.kt | 28 ++++ .../java/com/example/data/HistoryDatabase.kt | 31 +++++ .../evaluator/MathExpressionEvaluator.kt | 33 ++++- .../example/model/CalculationHistoryItem.kt | 6 +- .../com/example/ui/components/CalcButton.kt | 9 ++ .../example/viewmodel/CalculatorViewModel.kt | 124 +++++++++++++---- .../example/MathExpressionEvaluatorTest.kt | 131 ++++++++++++++++++ 13 files changed, 493 insertions(+), 67 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 LICENSE create mode 100644 README.md create mode 100644 SECURITY.md create mode 100644 app/src/main/java/com/example/data/HistoryDao.kt create mode 100644 app/src/main/java/com/example/data/HistoryDatabase.kt create mode 100644 app/src/test/java/com/example/MathExpressionEvaluatorTest.kt diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4914aa0 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,41 @@ +name: CI + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + build-and-test: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up JDK + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '21' + + # The repo does not commit the Gradle wrapper JAR, so install a known + # good Gradle version directly (matches gradle-wrapper.properties). + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v4 + with: + gradle-version: '9.3.1' + + - name: Build debug APK + run: gradle --no-daemon :app:assembleDebug + + - name: Run unit tests (Robolectric) + run: gradle --no-daemon :app:testDebugUnitTest + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: unit-test-results + path: app/build/test-results/**/*.xml + if-no-files-found: ignore diff --git a/.gitignore b/.gitignore index 2cbead9..0cbdc9c 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,10 @@ local.properties .env debug.keystore + +# Secrets: never commit signing material or env files +my-upload-key.jks +*.jks +keystore.properties +*.keystore +!**/debug.keystore diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5f14f0f --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 rodri2482 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..bdb079d --- /dev/null +++ b/README.md @@ -0,0 +1,59 @@ +# CalcQuick + +A fast, multi-mode calculator for Android built with **Jetpack Compose** and **Material 3**. + +> Internal project name: *Apex Calc* + +## Features + +- **Standard & Scientific modes** — live expression preview as you type, DEG/RAD toggle, memory keys, factorial, powers, percent, implicit multiplication (`2(3)`, `2PI`) +- **Unit Converter** — length, weight, temperature, area, volume, speed, time, data storage, energy +- **Loan calculator** — monthly payment, total payment, total interest +- **Tip & Split** — bill, tip %, per-person totals +- **Discount + Tax** — final price and savings +- **BMI** — metric and imperial +- **History** — persisted across app restarts (Room), starred favorites, recall into the display +- **Themes** — Neon Cyber (dark), OLED Black, Clean Light, Midnight Blue +- Haptic feedback, safe-drawing edge-to-edge UI + +## Tech stack + +| Layer | Choice | +|------------|-----------------------------------------------| +| UI | Jetpack Compose + Material 3 | +| State | MVVM (`StateFlow` + `AndroidViewModel`) | +| Evaluation | Custom zero-dependency recursive-descent parser (no `eval`, no code-execution surface) | +| Storage | Room (calculation history) · SharedPreferences (settings) | +| Build | Kotlin 2.2, AGP 9, Gradle 9, KSP, R8-minified release | + +## Building + +Requirements: JDK 21, Android SDK 36. + +```bash +gradle :app:assembleDebug # debug build +gradle :app:testDebugUnitTest # unit tests (Robolectric) +``` + +> If you use Android Studio, open the project and build normally. In CI, +> `gradle/actions/setup-gradle` with Gradle 9.3.1 is used because the wrapper +> JAR is not committed. + +## Tests + +`app/src/test/java/com/example/MathExpressionEvaluatorTest.kt` covers the +expression engine: precedence, division-by-zero, percent/factorial semantics, +degree/radian trigonometry, locale-safe decimal formatting, overflow, and the +paren-nesting crash guard. + +## Security notes + +- No network permissions; the release minified with R8. +- `.env` and keystores are gitignored; the `.env.example` placeholder key is + intentionally left commented out — do not bake API keys into `BuildConfig` + of a shipping APK (they are trivially extractable). +- See [SECURITY.md](SECURITY.md) for reporting issues. + +## License + +[MIT](LICENSE) diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..9c35f7c --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,28 @@ +# Security Policy + +## Supported versions + +The latest commit on `main` is supported. Always update before reporting an +issue that is already fixed. + +## Reporting a vulnerability + +Please do **not** open a public GitHub issue for security reports. + +1. Email the owner via the contact in their GitHub profile, **or** +2. Use GitHub's private vulnerability reporting + (Repository → Security → Report a vulnerability). + +Include: affected version/commit, a minimal reproduction, and (if possible) a +suggested fix. You can expect an initial response within 7 days. + +## Known design constraints + +- The expression parser has a nesting limit (150 parentheses) to prevent + stack-overflow crashes from pathological input. +- No API key is baked into the APK. Never uncomment `GEMINI_API_KEY` in + `.env.example` for release builds — keys shipped in an APK are extractable + by anyone with the binary. Use a backend proxy if server-side AI is needed. +- Release builds are sign-configured via environment variables + (`STORE_PASSWORD`, `KEY_PASSWORD`, `KEYSTORE_PATH`); never commit the + upload keystore (`my-upload-key.jks` is gitignored). diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ec66e24..c62211a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -43,7 +43,10 @@ android { buildTypes { release { isCrunchPngs = false - isMinifyEnabled = false + // R8 shrinking removes unused library code, shrinks resources, and + // obfuscates the release APK. + isMinifyEnabled = true + isShrinkResources = true proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") // Do not remove or modify this signingConfig assignment. It is necessary for Android apps in // AI Studio. @@ -80,17 +83,13 @@ secrets { googleServices { missingGoogleServicesStrategy = MissingGoogleServicesStrategy.WARN } -// Some unused dependencies are commented out below instead of being removed. -// This makes it easy to add them back in the future if needed. +// Unused dependencies were removed: retrofit, okhttp, logging-interceptor, +// moshi (+ codegen), firebase-bom/ai/appcheck (recaptcha AND debug), because +// the app neither initializes Firebase nor performs any networking. +// Re-add any of them only when the feature that uses them actually ships. dependencies { implementation(platform(libs.androidx.compose.bom)) - implementation(platform(libs.firebase.bom)) - // implementation(libs.accompanist.permissions) implementation(libs.androidx.activity.compose) - // implementation(libs.androidx.camera.camera2) - // implementation(libs.androidx.camera.core) - // implementation(libs.androidx.camera.lifecycle) - // implementation(libs.androidx.camera.view) implementation(libs.androidx.compose.material.icons.core) implementation(libs.androidx.compose.material.icons.extended) implementation(libs.androidx.compose.material3) @@ -98,36 +97,14 @@ dependencies { implementation(libs.androidx.compose.ui.graphics) implementation(libs.androidx.compose.ui.tooling.preview) implementation(libs.androidx.core.ktx) - // implementation(libs.androidx.datastore.preferences) implementation(libs.androidx.lifecycle.runtime.compose) implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.lifecycle.viewmodel.compose) - // implementation(libs.androidx.navigation.compose) + // Room: persisted calculation history implementation(libs.androidx.room.ktx) implementation(libs.androidx.room.runtime) - // implementation(libs.coil.compose) - implementation(libs.converter.moshi) - implementation(libs.firebase.ai) - // Uncomment to use Firestore: - // implementation(libs.firebase.firestore) - - // Uncomment ALL FOUR of the following dependencies together to use Firebase Auth and Google - // Sign-In via Credential Manager: - // implementation(libs.firebase.auth) - // implementation(libs.androidx.credentials) - // implementation(libs.androidx.credentials.play.services) - // implementation(libs.googleid) - implementation(libs.firebase.appcheck.recaptcha) - implementation(libs.firebase.appcheck.debug) implementation(libs.kotlinx.coroutines.android) implementation(libs.kotlinx.coroutines.core) - implementation(libs.logging.interceptor) - implementation(libs.moshi.kotlin) - implementation(libs.okhttp) - // implementation(libs.play.services.location) - implementation(libs.retrofit) - testImplementation(libs.androidx.compose.ui.test.junit4) - testImplementation(libs.androidx.core) testImplementation(libs.androidx.junit) testImplementation(libs.junit) testImplementation(libs.kotlinx.coroutines.test) @@ -140,5 +117,4 @@ dependencies { debugImplementation(libs.androidx.compose.ui.test.manifest) debugImplementation(libs.androidx.compose.ui.tooling) "ksp"(libs.androidx.room.compiler) - "ksp"(libs.moshi.kotlin.codegen) } diff --git a/app/src/main/java/com/example/data/HistoryDao.kt b/app/src/main/java/com/example/data/HistoryDao.kt new file mode 100644 index 0000000..1c400b2 --- /dev/null +++ b/app/src/main/java/com/example/data/HistoryDao.kt @@ -0,0 +1,28 @@ +package com.example.data + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.example.model.CalculationHistoryItem +import kotlinx.coroutines.flow.Flow + +@Dao +interface HistoryDao { + + /** + * Latest history entries, capped so memory can't grow unboundedly + * during long sessions. + */ + @Query("SELECT * FROM history ORDER BY timestamp DESC LIMIT 100") + fun observeAll(): Flow> + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(item: CalculationHistoryItem) + + @Query("UPDATE history SET isFavorite = (CASE WHEN isFavorite = 1 THEN 0 ELSE 1 END) WHERE id = :itemId") + suspend fun toggleFavorite(itemId: String) + + @Query("DELETE FROM history") + suspend fun clearAll() +} diff --git a/app/src/main/java/com/example/data/HistoryDatabase.kt b/app/src/main/java/com/example/data/HistoryDatabase.kt new file mode 100644 index 0000000..7de5fc9 --- /dev/null +++ b/app/src/main/java/com/example/data/HistoryDatabase.kt @@ -0,0 +1,31 @@ +package com.example.data + +import android.content.Context +import androidx.room.Database +import androidx.room.Room +import androidx.room.RoomDatabase +import com.example.model.CalculationHistoryItem + +@Database( + entities = [CalculationHistoryItem::class], + version = 1, + exportSchema = false +) +abstract class HistoryDatabase : RoomDatabase() { + + abstract fun historyDao(): HistoryDao + + companion object { + @Volatile + private var INSTANCE: HistoryDatabase? = null + + fun get(context: Context): HistoryDatabase = + INSTANCE ?: synchronized(this) { + INSTANCE ?: Room.databaseBuilder( + context.applicationContext, + HistoryDatabase::class.java, + "calcquick.db" + ).build().also { INSTANCE = it } + } + } +} diff --git a/app/src/main/java/com/example/evaluator/MathExpressionEvaluator.kt b/app/src/main/java/com/example/evaluator/MathExpressionEvaluator.kt index 48383f8..f0b54a2 100644 --- a/app/src/main/java/com/example/evaluator/MathExpressionEvaluator.kt +++ b/app/src/main/java/com/example/evaluator/MathExpressionEvaluator.kt @@ -2,19 +2,27 @@ package com.example.evaluator import java.math.BigDecimal import java.math.RoundingMode +import java.util.Locale object MathExpressionEvaluator { class EvaluationException(message: String) : Exception(message) + // Recursion guard: the recursive-descent parser recurses once per nested + // parentheses/function level. Cap nesting so a user cannot trigger a + // StackOverflowError (an Error, which is NOT caught by catch blocks below) + // by mashing the "(" button. + private const val MAX_PAREN_NESTING = 150 + fun evaluate(expression: String, isDegreeMode: Boolean = true, decimalPrecision: Int = 8): String { if (expression.isBlank()) return "" try { val sanitized = sanitize(expression) if (sanitized.isEmpty()) return "" + guardNesting(sanitized) val result = Parser(sanitized, isDegreeMode).parse() if (result.isNaN()) throw EvaluationException("Undefined") - if (result.isInfinite()) throw EvaluationException("Cannot divide by zero") + if (result.isInfinite()) throw EvaluationException("Result too large") return formatResult(result, decimalPrecision) } catch (e: EvaluationException) { @@ -29,7 +37,9 @@ object MathExpressionEvaluator { return try { val sanitized = sanitize(expression) if (sanitized.isEmpty()) return null - + + guardNesting(sanitized) + // Auto-close open parentheses for preview calculation var balanced = sanitized val openParens = balanced.count { it == '(' } @@ -57,15 +67,26 @@ object MathExpressionEvaluator { .trim() } + private fun guardNesting(sanitized: String) { + if (sanitized.count { it == '(' } > MAX_PAREN_NESTING) { + throw EvaluationException("Expression too complex") + } + } + private fun formatResult(value: Double, precision: Int): String { - // Handle whole numbers cleanly + // Handle whole numbers cleanly (Locale.US so the decimal point is + // always '.' internally; the UI formats for the user's locale) if (value % 1.0 == 0.0 && Math.abs(value) < 1e12) { - return String.format("%.0f", value) + return String.format(Locale.US, "%.0f", value) } - + // Handle scientific notation for extremely large/small values if (Math.abs(value) >= 1e12 || (Math.abs(value) < 1e-6 && value != 0.0)) { - return String.format("%.6e", value).replace("e+", "E").replace("e-", "E-") + return String.format( + Locale.US, + "%.6e", + value + ).replace("e+", "E").replace("e-", "E-") } val bd = BigDecimal(value.toString()).setScale(precision, RoundingMode.HALF_UP).stripTrailingZeros() diff --git a/app/src/main/java/com/example/model/CalculationHistoryItem.kt b/app/src/main/java/com/example/model/CalculationHistoryItem.kt index c895185..3c7c304 100644 --- a/app/src/main/java/com/example/model/CalculationHistoryItem.kt +++ b/app/src/main/java/com/example/model/CalculationHistoryItem.kt @@ -1,7 +1,11 @@ package com.example.model +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "history") data class CalculationHistoryItem( - val id: String = java.util.UUID.randomUUID().toString(), + @PrimaryKey val id: String = java.util.UUID.randomUUID().toString(), val modeName: String, val expression: String, val result: String, diff --git a/app/src/main/java/com/example/ui/components/CalcButton.kt b/app/src/main/java/com/example/ui/components/CalcButton.kt index cfdacd2..f2a1012 100644 --- a/app/src/main/java/com/example/ui/components/CalcButton.kt +++ b/app/src/main/java/com/example/ui/components/CalcButton.kt @@ -21,6 +21,9 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.scale +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.testTag @@ -87,6 +90,12 @@ fun CalcButton( .scale(scale) .clip(RoundedCornerShape(16.dp)) .background(backgroundColor) + // Accessibility: expose role + a readable label so TalkBack + // announces operator symbols correctly instead of silence. + .semantics { + role = Role.Button + contentDescription = text + } .clickable( interactionSource = interactionSource, indication = null, diff --git a/app/src/main/java/com/example/viewmodel/CalculatorViewModel.kt b/app/src/main/java/com/example/viewmodel/CalculatorViewModel.kt index 5a7733c..9baf838 100644 --- a/app/src/main/java/com/example/viewmodel/CalculatorViewModel.kt +++ b/app/src/main/java/com/example/viewmodel/CalculatorViewModel.kt @@ -1,7 +1,10 @@ package com.example.viewmodel -import androidx.lifecycle.ViewModel +import android.app.Application +import android.content.Context +import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope +import com.example.data.HistoryDatabase import com.example.evaluator.MathExpressionEvaluator import com.example.model.CalcMode import com.example.model.CalculationHistoryItem @@ -11,10 +14,13 @@ import com.example.model.UnitItem import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import java.math.BigDecimal import java.math.RoundingMode +import java.util.Locale import kotlin.math.pow enum class ThemeStyle(val displayName: String) { @@ -81,12 +87,22 @@ data class CalculatorUiState( val hapticEnabled: Boolean = true ) -class CalculatorViewModel : ViewModel() { +class CalculatorViewModel(application: Application) : AndroidViewModel(application) { - private val _uiState = MutableStateFlow(CalculatorUiState()) + private val prefs = application.getSharedPreferences("calcquick_prefs", Context.MODE_PRIVATE) + + private val _uiState = MutableStateFlow(CalculatorUiState( + themeStyle = restoreTheme(), + soundEnabled = prefs.getBoolean(PREF_SOUND, true), + hapticEnabled = prefs.getBoolean(PREF_HAPTIC, true), + isDegreeMode = prefs.getBoolean(PREF_DEGREE_MODE, true), + memoryValue = Double.fromBits(prefs.getLong(PREF_MEMORY_BITS, 0L)), + hasMemory = prefs.getBoolean(PREF_HAS_MEMORY, false) + )) val uiState: StateFlow = _uiState.asStateFlow() init { + observeHistory() updateLivePreview() recalculateConverter() recalculateLoan() @@ -95,20 +111,42 @@ class CalculatorViewModel : ViewModel() { recalculateBmi() } + /** + * Calculation history is the source of truth in Room; the UI state just + * mirrors it. Settings/theme/memory live in SharedPreferences so they + * survive process death. + */ + private fun observeHistory() { + HistoryDatabase.get(getApplication()) + .historyDao() + .observeAll() + .onEach { items -> _uiState.update { it.copy(history = items) } } + .launchIn(viewModelScope) + } + + private fun restoreTheme(): ThemeStyle = + ThemeStyle.entries.firstOrNull { it.name == prefs.getString(PREF_THEME, null) } + ?: ThemeStyle.NEON_CYBER + fun setMode(mode: CalcMode) { _uiState.update { it.copy(activeMode = mode, errorMessage = null) } } fun setThemeStyle(theme: ThemeStyle) { + prefs.edit().putString(PREF_THEME, theme.name).apply() _uiState.update { it.copy(themeStyle = theme) } } fun toggleSound() { - _uiState.update { it.copy(soundEnabled = !it.soundEnabled) } + val newValue = !_uiState.value.soundEnabled + prefs.edit().putBoolean(PREF_SOUND, newValue).apply() + _uiState.update { it.copy(soundEnabled = newValue) } } fun toggleHaptic() { - _uiState.update { it.copy(hapticEnabled = !it.hapticEnabled) } + val newValue = !_uiState.value.hapticEnabled + prefs.edit().putBoolean(PREF_HAPTIC, newValue).apply() + _uiState.update { it.copy(hapticEnabled = newValue) } } // --- Calculator Engine Actions --- @@ -160,7 +198,9 @@ class CalculatorViewModel : ViewModel() { } fun toggleDegreeRadian() { - _uiState.update { it.copy(isDegreeMode = !it.isDegreeMode) } + val newValue = !_uiState.value.isDegreeMode + prefs.edit().putBoolean(PREF_DEGREE_MODE, newValue).apply() + _uiState.update { it.copy(isDegreeMode = newValue) } updateLivePreview() } @@ -190,20 +230,22 @@ class CalculatorViewModel : ViewModel() { decimalPrecision = currentState.decimalPrecision ) - // Save to history + // Save to history (persisted in Room; ViewModel cleanup is free) val newHistoryItem = CalculationHistoryItem( modeName = currentState.activeMode.title, expression = expr, result = result ) + viewModelScope.launch { + HistoryDatabase.get(getApplication()).historyDao().insert(newHistoryItem) + } _uiState.update { state -> state.copy( expression = result, cursorPosition = result.length, livePreview = null, - errorMessage = null, - history = listOf(newHistoryItem) + state.history + errorMessage = null ) } } catch (e: Exception) { @@ -224,12 +266,15 @@ class CalculatorViewModel : ViewModel() { // --- Memory Functions --- fun memoryClear() { + prefs.edit().putLong(PREF_MEMORY_BITS, 0L).putBoolean(PREF_HAS_MEMORY, false).apply() _uiState.update { it.copy(memoryValue = 0.0, hasMemory = false) } } fun memoryRecall() { val mem = _uiState.value.memoryValue - val formattedMem = if (mem % 1.0 == 0.0) String.format("%.0f", mem) else mem.toString() + // Locale.US: this string feeds the expression parser; the parser + // (and Double parser) always expects '.' as the decimal separator. + val formattedMem = if (mem % 1.0 == 0.0) String.format(Locale.US, "%.0f", mem) else mem.toString() onKeyInput(formattedMem) } @@ -239,6 +284,7 @@ class CalculatorViewModel : ViewModel() { ?: currentState.expression.toDoubleOrNull() ?: return val newMem = currentState.memoryValue + valueToAdd + prefs.edit().putLong(PREF_MEMORY_BITS, newMem.toRawBits()).putBoolean(PREF_HAS_MEMORY, true).apply() _uiState.update { it.copy(memoryValue = newMem, hasMemory = true) } } @@ -248,6 +294,7 @@ class CalculatorViewModel : ViewModel() { ?: currentState.expression.toDoubleOrNull() ?: return val newMem = currentState.memoryValue - valueToSub + prefs.edit().putLong(PREF_MEMORY_BITS, newMem.toRawBits()).putBoolean(PREF_HAS_MEMORY, true).apply() _uiState.update { it.copy(memoryValue = newMem, hasMemory = true) } } @@ -293,7 +340,7 @@ class CalculatorViewModel : ViewModel() { val res = UnitConverterData.convert(valDouble, state.converterCategory, state.fromUnit, state.toUnit) val formattedRes = if (res % 1.0 == 0.0 && Math.abs(res) < 1e9) { - String.format("%.0f", res) + String.format(Locale.US, "%.0f", res) } else { BigDecimal(res).setScale(state.decimalPrecision, RoundingMode.HALF_UP).stripTrailingZeros().toPlainString() } @@ -332,18 +379,32 @@ class CalculatorViewModel : ViewModel() { } val monthlyRate = annualRate / 100.0 / 12.0 - val totalMonths = (years * 12).toInt() - val monthlyPayment = (p * monthlyRate * (1 + monthlyRate).pow(totalMonths)) / - ((1 + monthlyRate).pow(totalMonths) - 1) + // Round (not truncate) so fractional years behave reasonably, and + // guard against 0 months, which previously produced (1+r)^0 - 1 = 0 + // and an Infinity monthly payment. + val totalMonths = Math.round(years * 12.0) + if (totalMonths < 1L) { + _uiState.update { + it.copy( + loanMonthlyPayment = "0.00", + loanTotalPayment = "0.00", + loanTotalInterest = "0.00" + ) + } + return + } + + val monthlyPayment = (p * monthlyRate * (1 + monthlyRate).pow(totalMonths.toInt())) / + ((1 + monthlyRate).pow(totalMonths.toInt()) - 1) val totalPayment = monthlyPayment * totalMonths val totalInterest = totalPayment - p _uiState.update { it.copy( - loanMonthlyPayment = String.format("%,.2f", monthlyPayment), - loanTotalPayment = String.format("%,.2f", totalPayment), - loanTotalInterest = String.format("%,.2f", totalInterest) + loanMonthlyPayment = String.format(Locale.US, "%,.2f", monthlyPayment), + loanTotalPayment = String.format(Locale.US, "%,.2f", totalPayment), + loanTotalInterest = String.format(Locale.US, "%,.2f", totalInterest) ) } } @@ -373,9 +434,9 @@ class CalculatorViewModel : ViewModel() { _uiState.update { it.copy( - tipAmount = String.format("%.2f", tipAmt), - tipTotalAmount = String.format("%.2f", total), - tipPerPersonAmount = String.format("%.2f", perPerson) + tipAmount = String.format(Locale.US, "%.2f", tipAmt), + tipTotalAmount = String.format(Locale.US, "%.2f", total), + tipPerPersonAmount = String.format(Locale.US, "%.2f", perPerson) ) } } @@ -406,8 +467,8 @@ class CalculatorViewModel : ViewModel() { _uiState.update { it.copy( - discountFinalPrice = String.format("%.2f", finalPrice), - discountSavedAmount = String.format("%.2f", saved) + discountFinalPrice = String.format(Locale.US, "%.2f", finalPrice), + discountSavedAmount = String.format(Locale.US, "%.2f", saved) ) } } @@ -469,15 +530,24 @@ class CalculatorViewModel : ViewModel() { } fun toggleFavoriteHistory(itemId: String) { - _uiState.update { state -> - val updated = state.history.map { - if (it.id == itemId) it.copy(isFavorite = !it.isFavorite) else it - } - state.copy(history = updated) + viewModelScope.launch { + HistoryDatabase.get(getApplication()).historyDao().toggleFavorite(itemId) } } fun clearHistory() { _uiState.update { it.copy(history = emptyList()) } + viewModelScope.launch { + HistoryDatabase.get(getApplication()).historyDao().clearAll() + } + } + + private companion object { + const val PREF_THEME = "theme_style" + const val PREF_SOUND = "sound_enabled" + const val PREF_HAPTIC = "haptic_enabled" + const val PREF_DEGREE_MODE = "degree_mode" + const val PREF_MEMORY_BITS = "memory_value_bits" + const val PREF_HAS_MEMORY = "has_memory" } } diff --git a/app/src/test/java/com/example/MathExpressionEvaluatorTest.kt b/app/src/test/java/com/example/MathExpressionEvaluatorTest.kt new file mode 100644 index 0000000..0f41fc1 --- /dev/null +++ b/app/src/test/java/com/example/MathExpressionEvaluatorTest.kt @@ -0,0 +1,131 @@ +package com.example + +import com.example.evaluator.MathExpressionEvaluator +import com.example.evaluator.MathExpressionEvaluator.EvaluationException +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Test + +/** + * Unit tests for the hand-written expression evaluator. + * + * These run on the plain JVM (no Android dependency in the evaluator), + * so they work locally and in CI without an emulator. + */ +class MathExpressionEvaluatorTest { + + // --- Basic arithmetic --- + + @Test + fun addition() = assertEquals("4", MathExpressionEvaluator.evaluate("2+2")) + + @Test + fun precedence() = assertEquals("14", MathExpressionEvaluator.evaluate("2+3*4")) + + @Test + fun parenthesesPrecedence() = assertEquals("20", MathExpressionEvaluator.evaluate("(2+3)*4")) + + @Test + fun divisionResultHasUsDecimalPoint() { + // Guards the locale bug: results must always parse with '.' as the + // decimal separator regardless of the device JVM default locale. + val result = MathExpressionEvaluator.evaluate("1/8") + assertEquals("0.125", result) + assertTrue(!result.contains(",")) + } + + // --- Errors --- + + @Test + fun divideByZeroThrows() { + assertEvaluationError("Cannot divide by zero") { MathExpressionEvaluator.evaluate("10/0") } + } + + @Test + fun sqrtOfNegativeThrowsDomainError() { + assertEvaluationError("Domain Error") { MathExpressionEvaluator.evaluate("sqrt(-1)") } + } + + @Test + fun factorialAbove170Throws() { + assertEvaluationError("Invalid Factorial") { MathExpressionEvaluator.evaluate("171!") } + } + + @Test + fun overflowReportsResultTooLarge() { + // Used to be misreported as "Cannot divide by zero" + assertEvaluationError("Result too large") { MathExpressionEvaluator.evaluate("9^99^9") } + } + + @Test + fun deepParenNestingFailsGracefully() { + // Used to throw a StackOverflowError (uncaught) and crash the app + val expression = "(".repeat(300) + assertEvaluationError { MathExpressionEvaluator.evaluate(expression) } + } + + @Test + fun syntaxErrorForGarbage() { + assertEvaluationError { MathExpressionEvaluator.evaluate("2++*") } + assertEvaluationError { MathExpressionEvaluator.evaluate("foo(2)") } + } + + // --- Percent & factorial --- + + @Test + fun percentIsPostfixDivideBy100() = + assertEquals("0.5", MathExpressionEvaluator.evaluate("50%")) + + @Test + fun factorial() = assertEquals("120", MathExpressionEvaluator.evaluate("5!")) + + // --- Powers & implicit multiplication --- + + @Test + fun powerOperator() = assertEquals("1024", MathExpressionEvaluator.evaluate("2^10")) + + @Test + fun implicitMultiplicationWithParentheses() = + assertEquals("6", MathExpressionEvaluator.evaluate("2(3)")) + + @Test + fun implicitMultiplicationWithConstants() { + // 2 * PI rounded to 4 dp + assertEquals("6.2832", MathExpressionEvaluator.evaluate("2PI", decimalPrecision = 4)) + } + + // --- Functions --- + + @Test + fun degreeModeTrig() { + assertEquals("1", MathExpressionEvaluator.evaluate("sin(90)", decimalPrecision = 4)) + assertEquals("0.5", MathExpressionEvaluator.evaluate("cos(60)", decimalPrecision = 4)) + } + + @Test + fun radianModeTrig() { + // sin(pi/2) = 1 + assertEquals("1", MathExpressionEvaluator.evaluate("sin(PI/2)", isDegreeMode = false, decimalPrecision = 6)) + } + + @Test + fun logarithms() { + assertEquals("3", MathExpressionEvaluator.evaluate("log(1000)", decimalPrecision = 4)) + assertEquals("2.3026", MathExpressionEvaluator.evaluate("ln(10)", decimalPrecision = 4)) + } + + private fun assertEvaluationError( + message: String? = null, + block: () -> String + ) { + try { + block() + fail("Expected EvaluationException but evaluation succeeded") + } catch (e: EvaluationException) { + if (message != null) assertEquals(message, e.message) + } catch (e: Throwable) { + fail("Expected EvaluationException but got ${e.javaClass.simpleName}: ${e.message}") + } + } +} From 8acc8801c3cef09bb4a6cbb61227fbe48971755b Mon Sep 17 00:00:00 2001 From: rodri2482 Date: Mon, 2 Nov 2026 15:50:32 +0100 Subject: [PATCH 2/4] Polish pass: percent semantics, modulo, BMI conversion, precision setting, namespace rename - Conventional percent semantics: 10+50% -> 15, 100-50% -> 50; plain 50% still divides by 100 - Fix unreachable binary modulo (postfix '%' consumed it first): 10%3 now evaluates to 1; modulo by zero raises a proper error - Scientific-notation results (1.5E12) are normalized back into 1.5*10^12 so they can be reused after '=' (was a Syntax Error) - BMI unit switch now converts entered height/weight instead of reinterpretating the same numbers - Decimal-places setting (2/4/6/8) exposed in the Settings dialog and persisted (decimalPrecision was dead state before) - Namespace/applicationId renamed com.example -> com.rodri2482.calcquick for Play Store publishing --- README.md | 3 +- app/build.gradle.kts | 6 +- .../calcquick}/ExampleInstrumentedTest.kt | 2 +- .../calcquick}/MainActivity.kt | 12 ++- .../calcquick}/data/HistoryDao.kt | 4 +- .../calcquick}/data/HistoryDatabase.kt | 4 +- .../evaluator/MathExpressionEvaluator.kt | 101 +++++++++++++++--- .../calcquick}/model/CalcMode.kt | 2 +- .../model/CalculationHistoryItem.kt | 2 +- .../calcquick}/model/UnitConverterData.kt | 2 +- .../calcquick}/ui/components/BmiView.kt | 2 +- .../calcquick}/ui/components/CalcButton.kt | 12 +-- .../calcquick}/ui/components/CalcDisplay.kt | 2 +- .../calcquick}/ui/components/ConverterView.kt | 8 +- .../calcquick}/ui/components/DiscountView.kt | 2 +- .../calcquick}/ui/components/HistorySheet.kt | 4 +- .../calcquick}/ui/components/LoanView.kt | 2 +- .../ui/components/ModeSelectorRow.kt | 4 +- .../calcquick}/ui/components/ScientificPad.kt | 2 +- .../ui/components/SettingsDialog.kt | 24 ++++- .../calcquick}/ui/components/StandardPad.kt | 2 +- .../calcquick}/ui/components/TipView.kt | 2 +- .../calcquick}/ui/theme/Color.kt | 2 +- .../calcquick}/ui/theme/Theme.kt | 4 +- .../calcquick}/ui/theme/Type.kt | 2 +- .../viewmodel/CalculatorViewModel.kt | 67 +++++++++--- .../calcquick}/ExampleRobolectricTest.kt | 2 +- .../calcquick}/ExampleUnitTest.kt | 8 +- .../calcquick}/MathExpressionEvaluatorTest.kt | 43 +++++++- 29 files changed, 251 insertions(+), 81 deletions(-) rename app/src/androidTest/java/com/{example => rodri2482/calcquick}/ExampleInstrumentedTest.kt (94%) rename app/src/main/java/com/{example => rodri2482/calcquick}/MainActivity.kt (96%) rename app/src/main/java/com/{example => rodri2482/calcquick}/data/HistoryDao.kt (88%) rename app/src/main/java/com/{example => rodri2482/calcquick}/data/HistoryDatabase.kt (88%) rename app/src/main/java/com/{example => rodri2482/calcquick}/evaluator/MathExpressionEvaluator.kt (70%) rename app/src/main/java/com/{example => rodri2482/calcquick}/model/CalcMode.kt (95%) rename app/src/main/java/com/{example => rodri2482/calcquick}/model/CalculationHistoryItem.kt (90%) rename app/src/main/java/com/{example => rodri2482/calcquick}/model/UnitConverterData.kt (99%) rename app/src/main/java/com/{example => rodri2482/calcquick}/ui/components/BmiView.kt (99%) rename app/src/main/java/com/{example => rodri2482/calcquick}/ui/components/CalcButton.kt (92%) rename app/src/main/java/com/{example => rodri2482/calcquick}/ui/components/CalcDisplay.kt (99%) rename app/src/main/java/com/{example => rodri2482/calcquick}/ui/components/ConverterView.kt (98%) rename app/src/main/java/com/{example => rodri2482/calcquick}/ui/components/DiscountView.kt (98%) rename app/src/main/java/com/{example => rodri2482/calcquick}/ui/components/HistorySheet.kt (98%) rename app/src/main/java/com/{example => rodri2482/calcquick}/ui/components/LoanView.kt (99%) rename app/src/main/java/com/{example => rodri2482/calcquick}/ui/components/ModeSelectorRow.kt (95%) rename app/src/main/java/com/{example => rodri2482/calcquick}/ui/components/ScientificPad.kt (99%) rename app/src/main/java/com/{example => rodri2482/calcquick}/ui/components/SettingsDialog.kt (76%) rename app/src/main/java/com/{example => rodri2482/calcquick}/ui/components/StandardPad.kt (99%) rename app/src/main/java/com/{example => rodri2482/calcquick}/ui/components/TipView.kt (99%) rename app/src/main/java/com/{example => rodri2482/calcquick}/ui/theme/Color.kt (96%) rename app/src/main/java/com/{example => rodri2482/calcquick}/ui/theme/Theme.kt (95%) rename app/src/main/java/com/{example => rodri2482/calcquick}/ui/theme/Type.kt (95%) rename app/src/main/java/com/{example => rodri2482/calcquick}/viewmodel/CalculatorViewModel.kt (88%) rename app/src/test/java/com/{example => rodri2482/calcquick}/ExampleRobolectricTest.kt (94%) rename app/src/test/java/com/{example => rodri2482/calcquick}/ExampleUnitTest.kt (88%) rename app/src/test/java/com/{example => rodri2482/calcquick}/MathExpressionEvaluatorTest.kt (73%) diff --git a/README.md b/README.md index bdb079d..dededdc 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ A fast, multi-mode calculator for Android built with **Jetpack Compose** and **M ## Features -- **Standard & Scientific modes** — live expression preview as you type, DEG/RAD toggle, memory keys, factorial, powers, percent, implicit multiplication (`2(3)`, `2PI`) +- **Standard & Scientific modes** — live expression preview as you type, DEG/RAD toggle, memory keys, factorial, powers, calculator-convention percent (`100-50%` → `50`), modulo (`10%3`), implicit multiplication (`2(3)`, `2PI`) - **Unit Converter** — length, weight, temperature, area, volume, speed, time, data storage, energy - **Loan calculator** — monthly payment, total payment, total interest - **Tip & Split** — bill, tip %, per-person totals @@ -23,6 +23,7 @@ A fast, multi-mode calculator for Android built with **Jetpack Compose** and **M | UI | Jetpack Compose + Material 3 | | State | MVVM (`StateFlow` + `AndroidViewModel`) | | Evaluation | Custom zero-dependency recursive-descent parser (no `eval`, no code-execution surface) | +| Package | `com.rodri2482.calcquick` (Play-Store-safe namespace) | | Storage | Room (calculation history) · SharedPreferences (settings) | | Build | Kotlin 2.2, AGP 9, Gradle 9, KSP, R8-minified release | diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c62211a..3d28f23 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,4 +1,4 @@ -import com.google.gms.googleservices.GoogleServicesPlugin.MissingGoogleServicesStrategy +import com.google.gms.googleservices.GoogleServicesPlugin.MissingGoogleServicesStrategy plugins { alias(libs.plugins.android.application) @@ -9,11 +9,11 @@ plugins { } android { - namespace = "com.example" + namespace = "com.rodri2482.calcquick" compileSdk { version = release(36) { minorApiLevel = 1 } } defaultConfig { - applicationId = "com.aistudio.calculator.apexcalc" + applicationId = "com.rodri2482.calcquick" minSdk = 24 targetSdk = 36 versionCode = 1 diff --git a/app/src/androidTest/java/com/example/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/rodri2482/calcquick/ExampleInstrumentedTest.kt similarity index 94% rename from app/src/androidTest/java/com/example/ExampleInstrumentedTest.kt rename to app/src/androidTest/java/com/rodri2482/calcquick/ExampleInstrumentedTest.kt index 5e4660c..a08a778 100644 --- a/app/src/androidTest/java/com/example/ExampleInstrumentedTest.kt +++ b/app/src/androidTest/java/com/rodri2482/calcquick/ExampleInstrumentedTest.kt @@ -1,4 +1,4 @@ -package com.example +package com.rodri2482.calcquick import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry diff --git a/app/src/main/java/com/example/MainActivity.kt b/app/src/main/java/com/rodri2482/calcquick/MainActivity.kt similarity index 96% rename from app/src/main/java/com/example/MainActivity.kt rename to app/src/main/java/com/rodri2482/calcquick/MainActivity.kt index ef26d8c..f5e15d3 100644 --- a/app/src/main/java/com/example/MainActivity.kt +++ b/app/src/main/java/com/rodri2482/calcquick/MainActivity.kt @@ -1,4 +1,4 @@ -package com.example +package com.rodri2482.calcquick import android.os.Bundle import androidx.activity.ComponentActivity @@ -12,10 +12,10 @@ import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.example.model.CalcMode -import com.example.ui.components.* -import com.example.ui.theme.ApexCalcTheme -import com.example.viewmodel.CalculatorViewModel +import com.rodri2482.calcquick.model.CalcMode +import com.rodri2482.calcquick.ui.components.* +import com.rodri2482.calcquick.ui.theme.ApexCalcTheme +import com.rodri2482.calcquick.viewmodel.CalculatorViewModel class MainActivity : ComponentActivity() { @@ -197,9 +197,11 @@ class MainActivity : ComponentActivity() { currentTheme = uiState.themeStyle, soundEnabled = uiState.soundEnabled, hapticEnabled = uiState.hapticEnabled, + decimalPrecision = uiState.decimalPrecision, onThemeSelected = { viewModel.setThemeStyle(it) }, onToggleSound = { viewModel.toggleSound() }, onToggleHaptic = { viewModel.toggleHaptic() }, + onPrecisionSelected = { viewModel.setDecimalPrecision(it) }, onDismiss = { showSettingsDialog = false } ) } diff --git a/app/src/main/java/com/example/data/HistoryDao.kt b/app/src/main/java/com/rodri2482/calcquick/data/HistoryDao.kt similarity index 88% rename from app/src/main/java/com/example/data/HistoryDao.kt rename to app/src/main/java/com/rodri2482/calcquick/data/HistoryDao.kt index 1c400b2..ab42c9f 100644 --- a/app/src/main/java/com/example/data/HistoryDao.kt +++ b/app/src/main/java/com/rodri2482/calcquick/data/HistoryDao.kt @@ -1,10 +1,10 @@ -package com.example.data +package com.rodri2482.calcquick.data import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query -import com.example.model.CalculationHistoryItem +import com.rodri2482.calcquick.model.CalculationHistoryItem import kotlinx.coroutines.flow.Flow @Dao diff --git a/app/src/main/java/com/example/data/HistoryDatabase.kt b/app/src/main/java/com/rodri2482/calcquick/data/HistoryDatabase.kt similarity index 88% rename from app/src/main/java/com/example/data/HistoryDatabase.kt rename to app/src/main/java/com/rodri2482/calcquick/data/HistoryDatabase.kt index 7de5fc9..7967de1 100644 --- a/app/src/main/java/com/example/data/HistoryDatabase.kt +++ b/app/src/main/java/com/rodri2482/calcquick/data/HistoryDatabase.kt @@ -1,10 +1,10 @@ -package com.example.data +package com.rodri2482.calcquick.data import android.content.Context import androidx.room.Database import androidx.room.Room import androidx.room.RoomDatabase -import com.example.model.CalculationHistoryItem +import com.rodri2482.calcquick.model.CalculationHistoryItem @Database( entities = [CalculationHistoryItem::class], diff --git a/app/src/main/java/com/example/evaluator/MathExpressionEvaluator.kt b/app/src/main/java/com/rodri2482/calcquick/evaluator/MathExpressionEvaluator.kt similarity index 70% rename from app/src/main/java/com/example/evaluator/MathExpressionEvaluator.kt rename to app/src/main/java/com/rodri2482/calcquick/evaluator/MathExpressionEvaluator.kt index f0b54a2..690a4b0 100644 --- a/app/src/main/java/com/example/evaluator/MathExpressionEvaluator.kt +++ b/app/src/main/java/com/rodri2482/calcquick/evaluator/MathExpressionEvaluator.kt @@ -1,4 +1,4 @@ -package com.example.evaluator +package com.rodri2482.calcquick.evaluator import java.math.BigDecimal import java.math.RoundingMode @@ -56,7 +56,7 @@ object MathExpressionEvaluator { } private fun sanitize(input: String): String { - return input + val replaced = input .replace("×", "*") .replace("÷", "/") .replace("−", "-") @@ -64,7 +64,39 @@ object MathExpressionEvaluator { .replace("√", "sqrt") .replace("²", "^2") .replace("³", "^3") - .trim() + return normalizeScientificNotation(replaced.trim()) + } + + /** + * Rewrites scientific notation ("1.5E12", "2.4e-9") into parser-ready + * "1.5*10^12" form. Needed because results formatted with an exponent + * (formatResult) feed back into the parser when the user keeps + * calculating, and the bare parser would misread 'E' as Euler's number. + * A trailing 'E' without exponent digits stays untouched (Euler). + */ + private fun normalizeScientificNotation(input: String): String { + if (input.none { it == 'E' || it == 'e' }) return input + val sb = StringBuilder(input.length + 16) + var i = 0 + while (i < input.length) { + val c = input[i] + if ((c == 'E' || c == 'e') && i > 0 && (input[i - 1].isDigit() || input[i - 1] == '.')) { + var j = i + 1 + if (j < input.length && (input[j] == '+' || input[j] == '-')) j++ + if (j < input.length && input[j].isDigit()) { + sb.append("*10^") + while (j < input.length && input[j].isDigit()) { + sb.append(input[j]) + j++ + } + i = j + continue + } + } + sb.append(c) + i++ + } + return sb.toString() } private fun guardNesting(sanitized: String) { @@ -119,25 +151,40 @@ object MathExpressionEvaluator { // Grammar: // expression = term | expression `+` term | expression `-` term - // term = factor | term `*` factor | term `/` factor | term `%` factor + // term = factor | term `*` factor | term `/` factor | term `%` factor (modulo) // factor = `+` factor | `-` factor | power // power = base `^` factor - // base = primary | primary `!` + // base = primary | primary `!` | postfix `%` (only at end of term) // primary = number | constant | function `(` expression `)` | `(` expression `)` + // + // Percent semantics (conventional calculator behavior): + // - "A +/- B%" means A +/- (B% of A) e.g. 100-50% = 50 + // - "A * B%" or standalone "%" means divide by 100 + // - "%" followed by a number is modulo e.g. 10%3 = 1 private fun parseExpression(): Double { var x = parseTerm() while (true) { when { - eat('+'.code) -> x += parseTerm() - eat('-'.code) -> x -= parseTerm() + eat('+'.code) -> x += parseTerm(x) + eat('-'.code) -> x -= parseTerm(x) else -> return x } } } - private fun parseTerm(): Double { - var x = parseFactor() + /** + * [percentBase] carries the left-hand result when this term is the + * right operand of + or - (conventional percent-of-operand behavior). + */ + private fun parseTerm(percentBase: Double? = null): Double { + val firstIsPercent = BooleanArray(1) + var x = parseFactor(firstIsPercent) + if (firstIsPercent[0] && percentBase != null) { + // A +/- B% == A +/- |B|% of A (the operand's own sign is + // absorbed, e.g. 10-50% == 10 - 5) + x = percentBase * Math.abs(x) + } while (true) { when { eat('*'.code) -> x *= parseFactor() @@ -148,6 +195,7 @@ object MathExpressionEvaluator { } eat('%'.code) -> { val right = parseFactor() + if (right == 0.0) throw EvaluationException("Cannot divide by zero") x %= right } else -> { @@ -162,11 +210,11 @@ object MathExpressionEvaluator { } } - private fun parseFactor(): Double { - if (eat('+'.code)) return parseFactor() - if (eat('-'.code)) return -parseFactor() + private fun parseFactor(percentHolder: BooleanArray? = null): Double { + if (eat('+'.code)) return parseFactor(percentHolder) + if (eat('-'.code)) return -parseFactor(percentHolder) - var x = parseBase() + var x = parseBase(percentHolder) // Handle power operator ^ if (eat('^'.code)) { @@ -177,7 +225,7 @@ object MathExpressionEvaluator { return x } - private fun parseBase(): Double { + private fun parseBase(percentHolder: BooleanArray? = null): Double { var x = parsePrimary() // Postfix factorial ! @@ -186,14 +234,35 @@ object MathExpressionEvaluator { x = factorial(x.toLong()).toDouble() } - // Postfix percentage % (if not followed by another term) - if (eat('%'.code)) { + // Postfix percentage % — only when it ENDS the term. When a + // number/parenthesis follows, '%' is the modulo operator instead + // (handled by parseTerm), so 10%3 works. + if (eatPercentAsPostfix()) { x /= 100.0 + if (percentHolder != null) percentHolder[0] = true } return x } + /** + * Consumes '%' when it means "divide by 100" (i.e. nothing meaning + * "value" follows); returns false when '%' starts a modulo operation. + */ + private fun eatPercentAsPostfix(): Boolean { + while (ch == ' '.code) nextChar() + if (ch != '%'.code) return false + var peek = pos + 1 + while (peek < str.length && str[peek] == ' ') peek++ + if (peek >= str.length) return true + val next = str[peek].code + return !(next in '0'.code..'9'.code || + next in 'a'.code..'z'.code || + next in 'A'.code..'Z'.code || + next == '('.code || + next == '.'.code) + } + private fun parsePrimary(): Double { val startPos = pos if (eat('('.code)) { diff --git a/app/src/main/java/com/example/model/CalcMode.kt b/app/src/main/java/com/rodri2482/calcquick/model/CalcMode.kt similarity index 95% rename from app/src/main/java/com/example/model/CalcMode.kt rename to app/src/main/java/com/rodri2482/calcquick/model/CalcMode.kt index 5105edd..21c801c 100644 --- a/app/src/main/java/com/example/model/CalcMode.kt +++ b/app/src/main/java/com/rodri2482/calcquick/model/CalcMode.kt @@ -1,4 +1,4 @@ -package com.example.model +package com.rodri2482.calcquick.model import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.* diff --git a/app/src/main/java/com/example/model/CalculationHistoryItem.kt b/app/src/main/java/com/rodri2482/calcquick/model/CalculationHistoryItem.kt similarity index 90% rename from app/src/main/java/com/example/model/CalculationHistoryItem.kt rename to app/src/main/java/com/rodri2482/calcquick/model/CalculationHistoryItem.kt index 3c7c304..e644704 100644 --- a/app/src/main/java/com/example/model/CalculationHistoryItem.kt +++ b/app/src/main/java/com/rodri2482/calcquick/model/CalculationHistoryItem.kt @@ -1,4 +1,4 @@ -package com.example.model +package com.rodri2482.calcquick.model import androidx.room.Entity import androidx.room.PrimaryKey diff --git a/app/src/main/java/com/example/model/UnitConverterData.kt b/app/src/main/java/com/rodri2482/calcquick/model/UnitConverterData.kt similarity index 99% rename from app/src/main/java/com/example/model/UnitConverterData.kt rename to app/src/main/java/com/rodri2482/calcquick/model/UnitConverterData.kt index 8577a04..646e061 100644 --- a/app/src/main/java/com/example/model/UnitConverterData.kt +++ b/app/src/main/java/com/rodri2482/calcquick/model/UnitConverterData.kt @@ -1,4 +1,4 @@ -package com.example.model +package com.rodri2482.calcquick.model enum class UnitCategory(val displayName: String) { LENGTH("Length"), diff --git a/app/src/main/java/com/example/ui/components/BmiView.kt b/app/src/main/java/com/rodri2482/calcquick/ui/components/BmiView.kt similarity index 99% rename from app/src/main/java/com/example/ui/components/BmiView.kt rename to app/src/main/java/com/rodri2482/calcquick/ui/components/BmiView.kt index 76da2d2..a94621d 100644 --- a/app/src/main/java/com/example/ui/components/BmiView.kt +++ b/app/src/main/java/com/rodri2482/calcquick/ui/components/BmiView.kt @@ -1,4 +1,4 @@ -package com.example.ui.components +package com.rodri2482.calcquick.ui.components import androidx.compose.foundation.background import androidx.compose.foundation.layout.* diff --git a/app/src/main/java/com/example/ui/components/CalcButton.kt b/app/src/main/java/com/rodri2482/calcquick/ui/components/CalcButton.kt similarity index 92% rename from app/src/main/java/com/example/ui/components/CalcButton.kt rename to app/src/main/java/com/rodri2482/calcquick/ui/components/CalcButton.kt index f2a1012..a011500 100644 --- a/app/src/main/java/com/example/ui/components/CalcButton.kt +++ b/app/src/main/java/com/rodri2482/calcquick/ui/components/CalcButton.kt @@ -1,4 +1,4 @@ -package com.example.ui.components +package com.rodri2482.calcquick.ui.components import android.view.HapticFeedbackConstants import androidx.compose.animation.core.animateFloatAsState @@ -32,11 +32,11 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.example.ui.theme.EqualsBtnBgNeon -import com.example.ui.theme.EqualsBtnTextDark -import com.example.ui.theme.FunctionBtnBgDark -import com.example.ui.theme.NumberBtnBgDark -import com.example.ui.theme.OperatorBtnBgDark +import com.rodri2482.calcquick.ui.theme.EqualsBtnBgNeon +import com.rodri2482.calcquick.ui.theme.EqualsBtnTextDark +import com.rodri2482.calcquick.ui.theme.FunctionBtnBgDark +import com.rodri2482.calcquick.ui.theme.NumberBtnBgDark +import com.rodri2482.calcquick.ui.theme.OperatorBtnBgDark enum class CalcButtonType { NUMBER, diff --git a/app/src/main/java/com/example/ui/components/CalcDisplay.kt b/app/src/main/java/com/rodri2482/calcquick/ui/components/CalcDisplay.kt similarity index 99% rename from app/src/main/java/com/example/ui/components/CalcDisplay.kt rename to app/src/main/java/com/rodri2482/calcquick/ui/components/CalcDisplay.kt index b58a9ed..346e47f 100644 --- a/app/src/main/java/com/example/ui/components/CalcDisplay.kt +++ b/app/src/main/java/com/rodri2482/calcquick/ui/components/CalcDisplay.kt @@ -1,4 +1,4 @@ -package com.example.ui.components +package com.rodri2482.calcquick.ui.components import android.content.ClipData import android.content.ClipboardManager diff --git a/app/src/main/java/com/example/ui/components/ConverterView.kt b/app/src/main/java/com/rodri2482/calcquick/ui/components/ConverterView.kt similarity index 98% rename from app/src/main/java/com/example/ui/components/ConverterView.kt rename to app/src/main/java/com/rodri2482/calcquick/ui/components/ConverterView.kt index 279a47c..97c8bf8 100644 --- a/app/src/main/java/com/example/ui/components/ConverterView.kt +++ b/app/src/main/java/com/rodri2482/calcquick/ui/components/ConverterView.kt @@ -1,4 +1,4 @@ -package com.example.ui.components +package com.rodri2482.calcquick.ui.components import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -19,9 +19,9 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.example.model.UnitCategory -import com.example.model.UnitConverterData -import com.example.model.UnitItem +import com.rodri2482.calcquick.model.UnitCategory +import com.rodri2482.calcquick.model.UnitConverterData +import com.rodri2482.calcquick.model.UnitItem @Composable fun ConverterView( diff --git a/app/src/main/java/com/example/ui/components/DiscountView.kt b/app/src/main/java/com/rodri2482/calcquick/ui/components/DiscountView.kt similarity index 98% rename from app/src/main/java/com/example/ui/components/DiscountView.kt rename to app/src/main/java/com/rodri2482/calcquick/ui/components/DiscountView.kt index a494577..62314ee 100644 --- a/app/src/main/java/com/example/ui/components/DiscountView.kt +++ b/app/src/main/java/com/rodri2482/calcquick/ui/components/DiscountView.kt @@ -1,4 +1,4 @@ -package com.example.ui.components +package com.rodri2482.calcquick.ui.components import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState diff --git a/app/src/main/java/com/example/ui/components/HistorySheet.kt b/app/src/main/java/com/rodri2482/calcquick/ui/components/HistorySheet.kt similarity index 98% rename from app/src/main/java/com/example/ui/components/HistorySheet.kt rename to app/src/main/java/com/rodri2482/calcquick/ui/components/HistorySheet.kt index a5267f0..bcbd4f2 100644 --- a/app/src/main/java/com/example/ui/components/HistorySheet.kt +++ b/app/src/main/java/com/rodri2482/calcquick/ui/components/HistorySheet.kt @@ -1,4 +1,4 @@ -package com.example.ui.components +package com.rodri2482.calcquick.ui.components import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* @@ -18,7 +18,7 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.example.model.CalculationHistoryItem +import com.rodri2482.calcquick.model.CalculationHistoryItem @OptIn(ExperimentalMaterial3Api::class) @Composable diff --git a/app/src/main/java/com/example/ui/components/LoanView.kt b/app/src/main/java/com/rodri2482/calcquick/ui/components/LoanView.kt similarity index 99% rename from app/src/main/java/com/example/ui/components/LoanView.kt rename to app/src/main/java/com/rodri2482/calcquick/ui/components/LoanView.kt index 967c404..dac11bf 100644 --- a/app/src/main/java/com/example/ui/components/LoanView.kt +++ b/app/src/main/java/com/rodri2482/calcquick/ui/components/LoanView.kt @@ -1,4 +1,4 @@ -package com.example.ui.components +package com.rodri2482.calcquick.ui.components import androidx.compose.foundation.background import androidx.compose.foundation.layout.* diff --git a/app/src/main/java/com/example/ui/components/ModeSelectorRow.kt b/app/src/main/java/com/rodri2482/calcquick/ui/components/ModeSelectorRow.kt similarity index 95% rename from app/src/main/java/com/example/ui/components/ModeSelectorRow.kt rename to app/src/main/java/com/rodri2482/calcquick/ui/components/ModeSelectorRow.kt index a8d5896..44183b0 100644 --- a/app/src/main/java/com/example/ui/components/ModeSelectorRow.kt +++ b/app/src/main/java/com/rodri2482/calcquick/ui/components/ModeSelectorRow.kt @@ -1,4 +1,4 @@ -package com.example.ui.components +package com.rodri2482.calcquick.ui.components import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement @@ -17,7 +17,7 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.example.model.CalcMode +import com.rodri2482.calcquick.model.CalcMode @Composable fun ModeSelectorRow( diff --git a/app/src/main/java/com/example/ui/components/ScientificPad.kt b/app/src/main/java/com/rodri2482/calcquick/ui/components/ScientificPad.kt similarity index 99% rename from app/src/main/java/com/example/ui/components/ScientificPad.kt rename to app/src/main/java/com/rodri2482/calcquick/ui/components/ScientificPad.kt index c034636..951bfc5 100644 --- a/app/src/main/java/com/example/ui/components/ScientificPad.kt +++ b/app/src/main/java/com/rodri2482/calcquick/ui/components/ScientificPad.kt @@ -1,4 +1,4 @@ -package com.example.ui.components +package com.rodri2482.calcquick.ui.components import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column diff --git a/app/src/main/java/com/example/ui/components/SettingsDialog.kt b/app/src/main/java/com/rodri2482/calcquick/ui/components/SettingsDialog.kt similarity index 76% rename from app/src/main/java/com/example/ui/components/SettingsDialog.kt rename to app/src/main/java/com/rodri2482/calcquick/ui/components/SettingsDialog.kt index b92c082..7352d46 100644 --- a/app/src/main/java/com/example/ui/components/SettingsDialog.kt +++ b/app/src/main/java/com/rodri2482/calcquick/ui/components/SettingsDialog.kt @@ -1,4 +1,4 @@ -package com.example.ui.components +package com.rodri2482.calcquick.ui.components import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* @@ -11,16 +11,18 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.example.viewmodel.ThemeStyle +import com.rodri2482.calcquick.viewmodel.ThemeStyle @Composable fun SettingsDialog( currentTheme: ThemeStyle, soundEnabled: Boolean, hapticEnabled: Boolean, + decimalPrecision: Int, onThemeSelected: (ThemeStyle) -> Unit, onToggleSound: () -> Unit, onToggleHaptic: () -> Unit, + onPrecisionSelected: (Int) -> Unit, onDismiss: () -> Unit ) { AlertDialog( @@ -56,6 +58,24 @@ fun SettingsDialog( Divider() + // Decimal precision + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("Decimal Places", fontSize = 14.sp, fontWeight = FontWeight.Bold) + SingleChoiceSegmentedButtonRow { + listOf(2, 4, 6, 8).forEachIndexed { index, places -> + SegmentedButton( + selected = decimalPrecision == places, + onClick = { onPrecisionSelected(places) }, + shape = SegmentedButtonDefaults.itemShape(index = index, count = 4) + ) { + Text("$places", fontSize = 14.sp) + } + } + } + } + + Divider() + // Feedback Toggles Row( modifier = Modifier.fillMaxWidth(), diff --git a/app/src/main/java/com/example/ui/components/StandardPad.kt b/app/src/main/java/com/rodri2482/calcquick/ui/components/StandardPad.kt similarity index 99% rename from app/src/main/java/com/example/ui/components/StandardPad.kt rename to app/src/main/java/com/rodri2482/calcquick/ui/components/StandardPad.kt index 6d0a15c..ec9823f 100644 --- a/app/src/main/java/com/example/ui/components/StandardPad.kt +++ b/app/src/main/java/com/rodri2482/calcquick/ui/components/StandardPad.kt @@ -1,4 +1,4 @@ -package com.example.ui.components +package com.rodri2482.calcquick.ui.components import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column diff --git a/app/src/main/java/com/example/ui/components/TipView.kt b/app/src/main/java/com/rodri2482/calcquick/ui/components/TipView.kt similarity index 99% rename from app/src/main/java/com/example/ui/components/TipView.kt rename to app/src/main/java/com/rodri2482/calcquick/ui/components/TipView.kt index 1fbe0be..888d76c 100644 --- a/app/src/main/java/com/example/ui/components/TipView.kt +++ b/app/src/main/java/com/rodri2482/calcquick/ui/components/TipView.kt @@ -1,4 +1,4 @@ -package com.example.ui.components +package com.rodri2482.calcquick.ui.components import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState diff --git a/app/src/main/java/com/example/ui/theme/Color.kt b/app/src/main/java/com/rodri2482/calcquick/ui/theme/Color.kt similarity index 96% rename from app/src/main/java/com/example/ui/theme/Color.kt rename to app/src/main/java/com/rodri2482/calcquick/ui/theme/Color.kt index fb43691..5b0b97a 100644 --- a/app/src/main/java/com/example/ui/theme/Color.kt +++ b/app/src/main/java/com/rodri2482/calcquick/ui/theme/Color.kt @@ -1,4 +1,4 @@ -package com.example.ui.theme +package com.rodri2482.calcquick.ui.theme import androidx.compose.ui.graphics.Color diff --git a/app/src/main/java/com/example/ui/theme/Theme.kt b/app/src/main/java/com/rodri2482/calcquick/ui/theme/Theme.kt similarity index 95% rename from app/src/main/java/com/example/ui/theme/Theme.kt rename to app/src/main/java/com/rodri2482/calcquick/ui/theme/Theme.kt index 1060851..ee6c0ae 100644 --- a/app/src/main/java/com/example/ui/theme/Theme.kt +++ b/app/src/main/java/com/rodri2482/calcquick/ui/theme/Theme.kt @@ -1,4 +1,4 @@ -package com.example.ui.theme +package com.rodri2482.calcquick.ui.theme import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.MaterialTheme @@ -6,7 +6,7 @@ import androidx.compose.material3.darkColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color -import com.example.viewmodel.ThemeStyle +import com.rodri2482.calcquick.viewmodel.ThemeStyle private val NeonColorScheme = darkColorScheme( primary = NeonPrimary, diff --git a/app/src/main/java/com/example/ui/theme/Type.kt b/app/src/main/java/com/rodri2482/calcquick/ui/theme/Type.kt similarity index 95% rename from app/src/main/java/com/example/ui/theme/Type.kt rename to app/src/main/java/com/rodri2482/calcquick/ui/theme/Type.kt index b631a6b..ba2cdfc 100644 --- a/app/src/main/java/com/example/ui/theme/Type.kt +++ b/app/src/main/java/com/rodri2482/calcquick/ui/theme/Type.kt @@ -1,4 +1,4 @@ -package com.example.ui.theme +package com.rodri2482.calcquick.ui.theme import androidx.compose.material3.Typography import androidx.compose.ui.text.TextStyle diff --git a/app/src/main/java/com/example/viewmodel/CalculatorViewModel.kt b/app/src/main/java/com/rodri2482/calcquick/viewmodel/CalculatorViewModel.kt similarity index 88% rename from app/src/main/java/com/example/viewmodel/CalculatorViewModel.kt rename to app/src/main/java/com/rodri2482/calcquick/viewmodel/CalculatorViewModel.kt index 9baf838..093133c 100644 --- a/app/src/main/java/com/example/viewmodel/CalculatorViewModel.kt +++ b/app/src/main/java/com/rodri2482/calcquick/viewmodel/CalculatorViewModel.kt @@ -1,16 +1,16 @@ -package com.example.viewmodel +package com.rodri2482.calcquick.viewmodel import android.app.Application import android.content.Context import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope -import com.example.data.HistoryDatabase -import com.example.evaluator.MathExpressionEvaluator -import com.example.model.CalcMode -import com.example.model.CalculationHistoryItem -import com.example.model.UnitCategory -import com.example.model.UnitConverterData -import com.example.model.UnitItem +import com.rodri2482.calcquick.data.HistoryDatabase +import com.rodri2482.calcquick.evaluator.MathExpressionEvaluator +import com.rodri2482.calcquick.model.CalcMode +import com.rodri2482.calcquick.model.CalculationHistoryItem +import com.rodri2482.calcquick.model.UnitCategory +import com.rodri2482.calcquick.model.UnitConverterData +import com.rodri2482.calcquick.model.UnitItem import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -97,7 +97,8 @@ class CalculatorViewModel(application: Application) : AndroidViewModel(applicati hapticEnabled = prefs.getBoolean(PREF_HAPTIC, true), isDegreeMode = prefs.getBoolean(PREF_DEGREE_MODE, true), memoryValue = Double.fromBits(prefs.getLong(PREF_MEMORY_BITS, 0L)), - hasMemory = prefs.getBoolean(PREF_HAS_MEMORY, false) + hasMemory = prefs.getBoolean(PREF_HAS_MEMORY, false), + decimalPrecision = prefs.getInt(PREF_DECIMAL_PRECISION, 6).coerceIn(2, 10) )) val uiState: StateFlow = _uiState.asStateFlow() @@ -137,6 +138,15 @@ class CalculatorViewModel(application: Application) : AndroidViewModel(applicati _uiState.update { it.copy(themeStyle = theme) } } + fun setDecimalPrecision(precision: Int) { + val clamped = precision.coerceIn(2, 10) + prefs.edit().putInt(PREF_DECIMAL_PRECISION, clamped).apply() + _uiState.update { + it.copy(decimalPrecision = clamped, livePreview = null) + } + updateLivePreview() + } + fun toggleSound() { val newValue = !_uiState.value.soundEnabled prefs.edit().putBoolean(PREF_SOUND, newValue).apply() @@ -476,16 +486,46 @@ class CalculatorViewModel(application: Application) : AndroidViewModel(applicati // --- BMI Calculator Actions --- fun onBmiInputChanged(height: String? = null, weight: String? = null, isMetric: Boolean? = null) { - _uiState.update { + _uiState.update { state -> + val newIsMetric = isMetric ?: state.bmiIsMetric + var newHeight = height ?: state.bmiHeight + var newWeight = weight ?: state.bmiWeight + + // When toggling the unit system, convert the already-entered + // values so they aren't reinterpreted in the wrong unit + // (175 cm became 175 inches before this fix). + if (isMetric != null && isMetric != state.bmiIsMetric) { + val h = state.bmiHeight.toDoubleOrNull() + val w = state.bmiWeight.toDoubleOrNull() + newHeight = if (isMetric) { + convertUnit(h) { it * 2.54 } // in -> cm + } else { + convertUnit(h) { it / 2.54 } // cm -> in + } + newWeight = if (isMetric) { + convertUnit(w) { it * 0.45359237 } // lb -> kg + } else { + convertUnit(w) { it / 0.45359237 } // kg -> lb + } + } + it.copy( - bmiHeight = height ?: it.bmiHeight, - bmiWeight = weight ?: it.bmiWeight, - bmiIsMetric = isMetric ?: it.bmiIsMetric + bmiHeight = newHeight, + bmiWeight = newWeight, + bmiIsMetric = newIsMetric ) } recalculateBmi() } + private fun convertUnit(value: Double?, converter: (Double) -> Double): String { + if (value == null) return "" + return BigDecimal.valueOf(converter(value)) + .setScale(1, RoundingMode.HALF_UP) + .stripTrailingZeros() + .toPlainString() + } + private fun recalculateBmi() { val state = _uiState.value val h = state.bmiHeight.toDoubleOrNull() ?: 0.0 @@ -549,5 +589,6 @@ class CalculatorViewModel(application: Application) : AndroidViewModel(applicati const val PREF_DEGREE_MODE = "degree_mode" const val PREF_MEMORY_BITS = "memory_value_bits" const val PREF_HAS_MEMORY = "has_memory" + const val PREF_DECIMAL_PRECISION = "decimal_precision" } } diff --git a/app/src/test/java/com/example/ExampleRobolectricTest.kt b/app/src/test/java/com/rodri2482/calcquick/ExampleRobolectricTest.kt similarity index 94% rename from app/src/test/java/com/example/ExampleRobolectricTest.kt rename to app/src/test/java/com/rodri2482/calcquick/ExampleRobolectricTest.kt index d1c96e6..7f38e08 100644 --- a/app/src/test/java/com/example/ExampleRobolectricTest.kt +++ b/app/src/test/java/com/rodri2482/calcquick/ExampleRobolectricTest.kt @@ -1,4 +1,4 @@ -package com.example +package com.rodri2482.calcquick import android.content.Context import androidx.test.core.app.ApplicationProvider diff --git a/app/src/test/java/com/example/ExampleUnitTest.kt b/app/src/test/java/com/rodri2482/calcquick/ExampleUnitTest.kt similarity index 88% rename from app/src/test/java/com/example/ExampleUnitTest.kt rename to app/src/test/java/com/rodri2482/calcquick/ExampleUnitTest.kt index 1f04bb1..e18b772 100644 --- a/app/src/test/java/com/example/ExampleUnitTest.kt +++ b/app/src/test/java/com/rodri2482/calcquick/ExampleUnitTest.kt @@ -1,8 +1,8 @@ -package com.example +package com.rodri2482.calcquick -import com.example.evaluator.MathExpressionEvaluator -import com.example.model.UnitCategory -import com.example.model.UnitConverterData +import com.rodri2482.calcquick.evaluator.MathExpressionEvaluator +import com.rodri2482.calcquick.model.UnitCategory +import com.rodri2482.calcquick.model.UnitConverterData import org.junit.Assert.* import org.junit.Test diff --git a/app/src/test/java/com/example/MathExpressionEvaluatorTest.kt b/app/src/test/java/com/rodri2482/calcquick/MathExpressionEvaluatorTest.kt similarity index 73% rename from app/src/test/java/com/example/MathExpressionEvaluatorTest.kt rename to app/src/test/java/com/rodri2482/calcquick/MathExpressionEvaluatorTest.kt index 0f41fc1..e576b71 100644 --- a/app/src/test/java/com/example/MathExpressionEvaluatorTest.kt +++ b/app/src/test/java/com/rodri2482/calcquick/MathExpressionEvaluatorTest.kt @@ -1,7 +1,7 @@ -package com.example +package com.rodri2482.calcquick -import com.example.evaluator.MathExpressionEvaluator -import com.example.evaluator.MathExpressionEvaluator.EvaluationException +import com.rodri2482.calcquick.evaluator.MathExpressionEvaluator +import com.rodri2482.calcquick.evaluator.MathExpressionEvaluator.EvaluationException import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Assert.fail @@ -77,6 +77,36 @@ class MathExpressionEvaluatorTest { fun percentIsPostfixDivideBy100() = assertEquals("0.5", MathExpressionEvaluator.evaluate("50%")) + @Test + fun percentOfOperandOnAddition() { + // Conventional: A + B% == A + A%*B + assertEquals("15", MathExpressionEvaluator.evaluate("10+50%")) + assertEquals("3", MathExpressionEvaluator.evaluate("2+50%")) + } + + @Test + fun percentOfOperandOnSubtraction() { + // Conventional: A - B% == A - A%*B + assertEquals("50", MathExpressionEvaluator.evaluate("100-50%")) + assertEquals("5", MathExpressionEvaluator.evaluate("10-50%")) + } + + @Test + fun multiplyByPercentDividesBy100() = + assertEquals("5", MathExpressionEvaluator.evaluate("10*50%")) + + @Test + fun moduloOperator() { + // Binary % was previously unreachable (postfix percent consumed it) + assertEquals("1", MathExpressionEvaluator.evaluate("10%3")) + assertEquals("0", MathExpressionEvaluator.evaluate("10%2")) + } + + @Test + fun moduloByZeroThrows() { + assertEvaluationError("Cannot divide by zero") { MathExpressionEvaluator.evaluate("10%0") } + } + @Test fun factorial() = assertEquals("120", MathExpressionEvaluator.evaluate("5!")) @@ -115,6 +145,13 @@ class MathExpressionEvaluatorTest { assertEquals("2.3026", MathExpressionEvaluator.evaluate("ln(10)", decimalPrecision = 4)) } + @Test + fun scientificResultsRemainParseable() { + // A result like "1.5E12" must be reusable as input (it is set as the + // expression after '='), so scientific notation is normalized + assertEquals("0", MathExpressionEvaluator.evaluate("1.5E12-1500000000000")) + } + private fun assertEvaluationError( message: String? = null, block: () -> String From 9a6e55b24d849afa55b94d4b2257019893bf2e57 Mon Sep 17 00:00:00 2001 From: rodri2482 Date: Mon, 2 Nov 2026 19:20:35 +0100 Subject: [PATCH 3/4] Verify parser semantics offline; wire up key-click sounds - All 36 parser scenarios validated via faithful JS reimplementation (a class-free port of the same algorithm); found+fixed a real bug where the peek-only percent check didn't consume '%', leaving it re-visible to the modulo branch (50% parsed as a syntax error) - Fix applied to eatPercentAsPostfix: consume on postfix success - Add conventional-semantics tests: 200*2+10% -> 440 (Windows/Google calculator convention), (2+50)*2-10% -> 93.6 - Sound Feedback toggle now actually plays a short system key tone: CompositionLocal(LocalFeedbackSound) provided from uiState.soundEnabled in MainActivity; CalcButton plays TONE_PROP_KEY when enabled --- .../com/rodri2482/calcquick/MainActivity.kt | 2 ++ .../evaluator/MathExpressionEvaluator.kt | 11 ++++++-- .../calcquick/ui/components/CalcButton.kt | 28 +++++++++++++++++++ .../calcquick/MathExpressionEvaluatorTest.kt | 8 ++++++ 4 files changed, 47 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/rodri2482/calcquick/MainActivity.kt b/app/src/main/java/com/rodri2482/calcquick/MainActivity.kt index f5e15d3..8ca40c7 100644 --- a/app/src/main/java/com/rodri2482/calcquick/MainActivity.kt +++ b/app/src/main/java/com/rodri2482/calcquick/MainActivity.kt @@ -31,6 +31,7 @@ class MainActivity : ComponentActivity() { var showSettingsDialog by remember { mutableStateOf(false) } ApexCalcTheme(themeStyle = uiState.themeStyle) { + CompositionLocalProvider(LocalFeedbackSound provides uiState.soundEnabled) { Surface( modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background @@ -205,6 +206,7 @@ class MainActivity : ComponentActivity() { onDismiss = { showSettingsDialog = false } ) } + } } } } diff --git a/app/src/main/java/com/rodri2482/calcquick/evaluator/MathExpressionEvaluator.kt b/app/src/main/java/com/rodri2482/calcquick/evaluator/MathExpressionEvaluator.kt index 690a4b0..1529cce 100644 --- a/app/src/main/java/com/rodri2482/calcquick/evaluator/MathExpressionEvaluator.kt +++ b/app/src/main/java/com/rodri2482/calcquick/evaluator/MathExpressionEvaluator.kt @@ -248,19 +248,26 @@ object MathExpressionEvaluator { /** * Consumes '%' when it means "divide by 100" (i.e. nothing meaning * "value" follows); returns false when '%' starts a modulo operation. + * Consuming is required so parseTerm's modulo branch doesn't see the + * same '%' again. */ private fun eatPercentAsPostfix(): Boolean { while (ch == ' '.code) nextChar() if (ch != '%'.code) return false var peek = pos + 1 while (peek < str.length && str[peek] == ' ') peek++ - if (peek >= str.length) return true + if (peek >= str.length) { + nextChar() + return true + } val next = str[peek].code - return !(next in '0'.code..'9'.code || + val isPostfix = !(next in '0'.code..'9'.code || next in 'a'.code..'z'.code || next in 'A'.code..'Z'.code || next == '('.code || next == '.'.code) + if (isPostfix) nextChar() + return isPostfix } private fun parsePrimary(): Double { diff --git a/app/src/main/java/com/rodri2482/calcquick/ui/components/CalcButton.kt b/app/src/main/java/com/rodri2482/calcquick/ui/components/CalcButton.kt index a011500..7583faa 100644 --- a/app/src/main/java/com/rodri2482/calcquick/ui/components/CalcButton.kt +++ b/app/src/main/java/com/rodri2482/calcquick/ui/components/CalcButton.kt @@ -1,5 +1,7 @@ package com.rodri2482.calcquick.ui.components +import android.media.AudioManager +import android.media.ToneGenerator import android.view.HapticFeedbackConstants import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween @@ -15,8 +17,10 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -46,6 +50,13 @@ enum class CalcButtonType { CLEAR } +/** + * App-wide "key click" sound switch. Provided once in MainActivity from + * CalculatorUiState.soundEnabled so every CalcButton in every pad reacts to + * the Settings toggle without threading the flag through all call sites. + */ +val LocalFeedbackSound = staticCompositionLocalOf { false } + @Composable fun CalcButton( text: String, @@ -60,6 +71,20 @@ fun CalcButton( val interactionSource = remember { MutableInteractionSource() } val isPressed by interactionSource.collectIsPressedAsState() val view = LocalView.current + val soundEnabled = LocalFeedbackSound.current + + // Short UI key-click, no audio assets needed. Repeated startTone() calls + // reuse the generator; released when the button leaves composition. + val toneGenerator = remember { + try { + ToneGenerator(AudioManager.STREAM_SYSTEM, 55) + } catch (_: RuntimeException) { + null // some devices fail when the stream is muted + } + } + DisposableEffect(toneGenerator) { + onDispose { toneGenerator?.release() } + } val scale by animateFloatAsState( targetValue = if (isPressed) 0.92f else 1.0f, @@ -103,6 +128,9 @@ fun CalcButton( if (hapticEnabled) { view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP) } + if (soundEnabled) { + toneGenerator?.startTone(ToneGenerator.TONE_PROP_KEY, 40) + } onClick() } ) diff --git a/app/src/test/java/com/rodri2482/calcquick/MathExpressionEvaluatorTest.kt b/app/src/test/java/com/rodri2482/calcquick/MathExpressionEvaluatorTest.kt index e576b71..21f27a2 100644 --- a/app/src/test/java/com/rodri2482/calcquick/MathExpressionEvaluatorTest.kt +++ b/app/src/test/java/com/rodri2482/calcquick/MathExpressionEvaluatorTest.kt @@ -95,6 +95,14 @@ class MathExpressionEvaluatorTest { fun multiplyByPercentDividesBy100() = assertEquals("5", MathExpressionEvaluator.evaluate("10*50%")) + @Test + fun percentOfRunningTotalMatchesConventionalCalculators() { + // Windows/Google calculator convention: the percent applies to the + // whole expression so far + assertEquals("440", MathExpressionEvaluator.evaluate("200*2+10%")) + assertEquals("93.6", MathExpressionEvaluator.evaluate("(2+50)*2-10%")) + } + @Test fun moduloOperator() { // Binary % was previously unreachable (postfix percent consumed it) From b09c61f13ef7d53beabb1dfb1498e322b3bb385f Mon Sep 17 00:00:00 2001 From: rodri2482 Date: Mon, 2 Nov 2026 21:43:44 +0100 Subject: [PATCH 4/4] Add APK deployment pipelines (CI debug artifact + signed release workflow) - CI now generates the debug keystore on the runner and uploads the debug-signed APK as a downloadable artifact every build - New release.yml: workflow_dispatch / v* tag triggered signed release build; keystore decoded from RELEASE_KEYSTORE_BASE64 secret, APK uploaded as artifact and attached to tag releases - build.gradle.kts: alias configurable via KEY_ALIAS env (defaults upload) - README: releasing/deploying section (debug APK, release APK, Play AAB) --- .github/workflows/ci.yml | 19 +++++++++ .github/workflows/release.yml | 72 +++++++++++++++++++++++++++++++++++ README.md | 16 ++++++++ app/build.gradle.kts | 2 +- 4 files changed, 108 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4914aa0..76db22a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,9 +26,28 @@ jobs: with: gradle-version: '9.3.1' + # The debug signing config expects a debug.keystore at the repo root + # (gitignored). Generate it on CI so the debug build can be signed. + - name: Generate debug keystore if missing + run: | + if [ ! -f debug.keystore ]; then + keytool -genkeypair -v -keystore debug.keystore -storetype PKCS12 \ + -alias androiddebugkey -keypass android -storepass android \ + -keyalg RSA -keysize 2048 -validity 10000 \ + -dname "CN=Android Debug,O=Android,C=US" + fi + - name: Build debug APK run: gradle --no-daemon :app:assembleDebug + # Downloadable installable APK on every run (Actions tab → artifacts) + - name: Upload debug APK artifact + uses: actions/upload-artifact@v4 + with: + name: CalcQuick-debug-apk + path: app/build/outputs/apk/debug/*.apk + if-no-files-found: error + - name: Run unit tests (Robolectric) run: gradle --no-daemon :app:testDebugUnitTest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..f1845e5 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,72 @@ +name: Release APK + +# Builds a SIGNED release APK ready to sideload (or convert to AAB for Play). +# +# Requirements — add these repository secrets (see SECURITY.md for why the +# keystore must never be committed): +# RELEASE_KEYSTORE_BASE64 — the upload keystore, base64-encoded +# RELEASE_STORE_PASSWORD — keystore password +# RELEASE_KEY_PASSWORD — key password +# RELEASE_KEY_ALIAS — normally "upload" +# +# Create the keystore locally once, then base64 it: +# keytool -genkeypair -v -keystore my-upload-key.jks -storetype PKCS12 \ +# -alias upload -keyalg RSA -keysize 2048 -validity 10000 \ +# -dname "CN=CalcQuick, O=rodri2482, C=US" +# powershell: [Convert]::ToBase64String([IO.File]::ReadAllBytes("my-upload-key.jks")) > base64.txt +# linux/mac: base64 -w0 my-upload-key.jks +# BACK UP this keystore — losing it means losing the ability to update the app. + +on: + workflow_dispatch: + push: + tags: [ 'v*' ] + +jobs: + signed-release: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up JDK + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '21' + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v4 + with: + gradle-version: '9.3.1' + + - name: Decode release keystore + env: + KEYSTORE_B64: ${{ secrets.RELEASE_KEYSTORE_BASE64 }} + run: | + if [ -z "$KEYSTORE_B64" ]; then + echo "::error::RELEASE_KEYSTORE_BASE64 secret is not set"; exit 1 + fi + echo "$KEYSTORE_B64" | base64 -d > my-upload-key.jks + chmod 600 my-upload-key.jks + + - name: Build signed release APK (R8 minified) + env: + STORE_PASSWORD: ${{ secrets.RELEASE_STORE_PASSWORD }} + KEY_ALIAS: ${{ secrets.RELEASE_KEY_ALIAS }} + KEY_PASSWORD: ${{ secrets.RELEASE_KEY_PASSWORD }} + run: gradle --no-daemon :app:assembleRelease + + # expose KEY_ALIAS as environment var also read by build.gradle.kts + - name: Upload signed APK artifact + uses: actions/upload-artifact@v4 + with: + name: CalcQuick-release-apk-${{ github.ref_name }} + path: app/build/outputs/apk/release/*.apk + if-no-files-found: error + + - name: Attach APK to tag release + if: startsWith(github.ref, 'refs/tags/') + uses: softprops/action-gh-release@v2 + with: + files: app/build/outputs/apk/release/*.apk diff --git a/README.md b/README.md index dededdc..d722a7a 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,22 @@ gradle :app:testDebugUnitTest # unit tests (Robolectric) > `gradle/actions/setup-gradle` with Gradle 9.3.1 is used because the wrapper > JAR is not committed. +## Releasing / Deploying + +- **Debug APK (fastest, zero setup):** every CI run on GitHub Actions uploads a + signed, installable `CalcQuick-debug-apk` artifact — download it from the run's + Artifacts section, copy it to a phone, and install (allow "Install unknown apps" for + the file manager). +- **Signed release APK (sideload-ready):** add the repository secrets + `RELEASE_KEYSTORE_BASE64`, `RELEASE_STORE_PASSWORD`, `RELEASE_KEY_PASSWORD` + (see `release.yml` header for how to create the keystore), then run the + **Release APK** workflow manually or push a `v*` tag. The signed APK is + uploaded as both an artifact and a GitHub Release asset. +- **Google Play:** click the developer fee ($25 one-time), create the app entry, + and upload an AAB built with `gradle :app:bundleRelease` using the same + environment variables. Bump `versionCode` before every upload; a release AAB + is required at store submission. Default setting: minSdk 24, targetSdk 36. + ## Tests `app/src/test/java/com/example/MathExpressionEvaluatorTest.kt` covers the diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3d28f23..ab32026 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -29,7 +29,7 @@ android { val keystorePath = System.getenv("KEYSTORE_PATH") ?: "${rootDir}/my-upload-key.jks" storeFile = file(keystorePath) storePassword = System.getenv("STORE_PASSWORD") - keyAlias = "upload" + keyAlias = System.getenv("KEY_ALIAS") ?: "upload" keyPassword = System.getenv("KEY_PASSWORD") } create("debugConfig") {