diff --git a/.github/workflows/android-sample.yml b/.github/workflows/android-sample.yml new file mode 100644 index 0000000..a32571d --- /dev/null +++ b/.github/workflows/android-sample.yml @@ -0,0 +1,86 @@ +name: Android Sample + +on: + pull_request: + branches: [ main, develop ] + workflow_dispatch: + +permissions: + contents: read + +jobs: + android-sample: + name: Build APK and test Android sample + runs-on: ubuntu-latest + timeout-minutes: 40 + steps: + - name: Check out code + uses: actions/checkout@v5 + + - name: Set up JDK 21 + uses: actions/setup-java@v5 + with: + distribution: zulu + java-version: 21 + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v5 + + - name: Set up Android SDK + uses: android-actions/setup-android@v3 + with: + packages: platform-tools + + - name: Install compile SDK + run: sdkmanager "platforms;android-37.0" + + - name: Build sample and instrumentation APKs + run: ./gradlew :sample:androidApp:assembleDebug :sample:androidApp:assembleDebugAndroidTest --stacktrace + + - name: Enable KVM + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + - name: Prepare emulator validation + run: | + cat > "$RUNNER_TEMP/run-android-sample.sh" <<'SCRIPT' + set -euo pipefail + mkdir -p build/android-validation + adb install -r sample/androidApp/build/outputs/apk/debug/androidApp-debug.apk + adb shell am start -W -n org.kimplify.sample/.MainActivity + adb exec-out screencap -p > build/android-validation/launch.png + test_exit_code=0 + ./gradlew :sample:androidApp:connectedDebugAndroidTest -Pandroid.enableAdditionalTestOutput=true --stacktrace || test_exit_code=$? + adb logcat -d > build/android-validation/logcat.txt || true + if [ "$test_exit_code" -eq 0 ]; then + find sample/androidApp/build/outputs/connected_android_test_additional_output -type f -name '*.png' -print -quit | grep -q . + fi + exit "$test_exit_code" + SCRIPT + + - name: Install, launch, and test on Android emulator + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: 36 + target: google_apis + arch: x86_64 + profile: pixel_7_pro + disable-animations: true + emulator-options: -no-window -gpu swiftshader_indirect -no-snapshot -noaudio -no-boot-anim -prop persist.sys.language=en -prop persist.sys.country=US + script: bash "$RUNNER_TEMP/run-android-sample.sh" + + - name: Upload APKs, Android test results, and screenshots + if: always() + uses: actions/upload-artifact@v5 + with: + name: android-sample-validation + path: | + sample/androidApp/build/outputs/apk/debug/*.apk + sample/androidApp/build/outputs/apk/androidTest/debug/*.apk + sample/androidApp/build/reports/androidTests/connected/ + sample/androidApp/build/outputs/androidTest-results/connected/ + sample/androidApp/build/outputs/connected_android_test_additional_output/ + build/android-validation/ + retention-days: 30 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e45cc60..e603f60 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -31,16 +31,16 @@ jobs: uses: gradle/actions/setup-gradle@v5 - name: Run JVM tests - run: ./gradlew :kurrency-core:jvmTest + run: ./gradlew :kurrency-core:jvmTest :kurrency-compose:jvmTest - name: Run iOS tests - run: ./gradlew :kurrency-core:iosSimulatorArm64Test + run: ./gradlew :kurrency-core:iosSimulatorArm64Test :kurrency-compose:iosSimulatorArm64Test - name: Run JS tests - run: ./gradlew :kurrency-core:jsTest + run: ./gradlew :kurrency-core:jsTest :kurrency-compose:jsTest - name: Run WasmJs tests - run: ./gradlew :kurrency-core:wasmJsTest + run: ./gradlew :kurrency-core:wasmJsTest :kurrency-compose:wasmJsTest - name: Upload test reports if: always() @@ -50,6 +50,8 @@ jobs: path: | kurrency-core/build/reports/tests/ kurrency-core/build/test-results/ + kurrency-compose/build/reports/tests/ + kurrency-compose/build/test-results/ retention-days: 30 build: diff --git a/CHANGELOG.md b/CHANGELOG.md index 054fb26..13907be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ All notable changes to the Kurrency library will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- `CurrencyOutputTransformation` and `rememberCurrencyOutputTransformation` for currency + input in state-based Compose text fields, with a sample and cross-platform tests. (#23) + ## [0.5.0] - 2026-08-20 A correctness release for the options API. Formatted output changes for three cases diff --git a/README.md b/README.md index a8dfb9e..ec178f9 100644 --- a/README.md +++ b/README.md @@ -164,6 +164,58 @@ val system = KurrencyLocale.systemLocale() Add `kurrency-compose` for Jetpack Compose Multiplatform support. +### Currency input with state-based text fields + +Use `rememberCurrencyOutputTransformation` with Foundation's state-based `BasicTextField`. +The state holds raw minor-unit digits: `"1234"` displays as `$12.34` for USD in the US locale. +Compose handles cursor and selection offsets for the formatted output. + +```kotlin +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.InputTransformation +import androidx.compose.foundation.text.input.TextFieldLineLimits +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.runtime.Composable +import androidx.compose.ui.text.input.KeyboardType +import org.kimplify.kurrency.KurrencyLocale +import org.kimplify.kurrency.compose.rememberCurrencyOutputTransformation + +@Composable +fun CurrencyInput() { + val state = rememberTextFieldState(initialText = "1234") + val transformation = rememberCurrencyOutputTransformation( + currencyCode = "USD", + locale = KurrencyLocale.US + ) + + BasicTextField( + state = state, + outputTransformation = transformation, + inputTransformation = InputTransformation { + if (asCharSequence().any { it !in '0'..'9' }) revertAllChanges() + }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + lineLimits = TextFieldLineLimits.SingleLine + ) +} +``` + +The optional input filter rejects an entire edit containing anything outside ASCII `0`–`9`; +the number keyboard alone does not validate input. Store digits without signs, decimal +separators, or currency symbols. Output formatting only changes the display, leaving +`state.text` untouched. Empty text displays formatted zero while remaining empty in the state. + +The default input scale comes from the currency: `"1234"` means USD 12.34, JPY 1234, or +BHD 1.234. Override `fractionDigits` to change that scale; it does not set display precision. +The platform formatter still uses the currency's normal fraction digits. If formatting fails, +including for an invalid currency code, the display falls back to the synthesized decimal amount. + +Omit `locale` to follow the current Compose locale. Outside composition, construct +`CurrencyOutputTransformation` directly; its default is the system locale. For +`value`/`onValueChange` text fields, `CurrencyVisualTransformation` and +`rememberCurrencyVisualTransformation` remain available. + ### rememberCurrencyFormatter The formatter is recreated when the locale changes (key-based recomposition). Formatting is cheap, so call it directly during composition rather than caching it. @@ -290,6 +342,18 @@ val groupingSeparator: Char ### Compose extensions (`kurrency-compose`) ```kotlin +class CurrencyOutputTransformation( + currencyCode: String, + locale: KurrencyLocale = KurrencyLocale.systemLocale(), + fractionDigits: Int = CurrencyFormatter.getFractionDigitsOrDefault(currencyCode).coerceAtLeast(0) +) : OutputTransformation + +@Composable fun rememberCurrencyOutputTransformation( + currencyCode: String, + locale: KurrencyLocale = KurrencyLocale.current(), + fractionDigits: Int = CurrencyFormatter.getFractionDigitsOrDefault(currencyCode).coerceAtLeast(0) +): CurrencyOutputTransformation + @Composable fun rememberCurrencyFormatter(locale: KurrencyLocale = KurrencyLocale.current()): CurrencyFormat @Composable fun rememberSystemCurrencyFormatter(): CurrencyFormat diff --git a/kurrency-compose/build.gradle.kts b/kurrency-compose/build.gradle.kts index fee5ae9..90a2491 100644 --- a/kurrency-compose/build.gradle.kts +++ b/kurrency-compose/build.gradle.kts @@ -33,7 +33,6 @@ kotlin { js(IR) { browser() - nodejs() } listOf( diff --git a/kurrency-compose/src/commonMain/kotlin/org/kimplify/kurrency/compose/CurrencyOutputTransformation.kt b/kurrency-compose/src/commonMain/kotlin/org/kimplify/kurrency/compose/CurrencyOutputTransformation.kt new file mode 100644 index 0000000..b8e5c1d --- /dev/null +++ b/kurrency-compose/src/commonMain/kotlin/org/kimplify/kurrency/compose/CurrencyOutputTransformation.kt @@ -0,0 +1,93 @@ +package org.kimplify.kurrency.compose + +import androidx.compose.foundation.text.input.OutputTransformation +import androidx.compose.foundation.text.input.TextFieldBuffer +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import org.kimplify.kurrency.CurrencyFormatter +import org.kimplify.kurrency.KurrencyLocale + +class CurrencyOutputTransformation( + private val currencyCode: String, + private val locale: KurrencyLocale = KurrencyLocale.systemLocale(), + private val fractionDigits: Int = CurrencyFormatter.getFractionDigitsOrDefault(currencyCode).coerceAtLeast(0), +) : OutputTransformation { + + init { + require(fractionDigits >= 0) { "fractionDigits must be nonnegative" } + } + + private val formatter = CurrencyFormatter(locale) + private val formattedZero by lazy { + formatter.formatCurrencyStyleResult(buildAmount(""), currencyCode) + } + private val displayFractionDigits by lazy { + formattedZero.getOrNull()?.count { it.isDigit() }?.minus(1)?.takeIf { it >= 0 } + } + + override fun TextFieldBuffer.transformOutput() { + val raw = asCharSequence().toString() + val digits = raw.filter { it.isDigit() } + val amount = buildAmount(digits) + val result = if (digits.isEmpty()) formattedZero else formatter.formatCurrencyStyleResult(amount, currencyCode) + val formatted = result.getOrDefault(amount) + val outputScale = if (result.isSuccess) displayFractionDigits else fractionDigits + + if (outputScale == null) { + replaceOutputRange(0, length, formatted) + } else { + applyCurrencyFormatting(raw, formatted, fractionDigits, outputScale) + } + } + + private fun buildAmount(digits: String): String { + if (fractionDigits == 0) return digits.ifEmpty { "0" } + val integerLength = digits.length - fractionDigits + return if (integerLength > 0) { + digits.take(integerLength) + "." + digits.drop(integerLength) + } else { + "0." + "0".repeat(-integerLength) + digits + } + } +} + +private fun TextFieldBuffer.applyCurrencyFormatting( + raw: String, + formatted: String, + inputScale: Int, + outputScale: Int, +) { + val sourceDigits = raw.indices.filter { raw[it].isDigit() } + val outputDigits = formatted.indices.filter { formatted[it].isDigit() } + val sourceExponent = sourceDigits.lastIndex - inputScale + val outputExponent = outputDigits.lastIndex - outputScale + val firstExponent = maxOf(-inputScale, -outputScale) + val lastExponent = minOf(sourceExponent, outputExponent) + var sourceEnd = raw.length + var outputEnd = formatted.length + + for (exponent in firstExponent..lastExponent) { + val sourceIndex = sourceDigits[sourceExponent - exponent] + val outputIndex = outputDigits[outputExponent - exponent] + replaceOutputRange(sourceIndex + 1, sourceEnd, formatted.substring(outputIndex + 1, outputEnd)) + replaceOutputRange(sourceIndex, sourceIndex + 1, formatted[outputIndex].toString()) + sourceEnd = sourceIndex + outputEnd = outputIndex + } + replaceOutputRange(0, sourceEnd, formatted.substring(0, outputEnd)) +} + +private fun TextFieldBuffer.replaceOutputRange(start: Int, end: Int, replacement: String) { + if (asCharSequence().subSequence(start, end).toString() != replacement) { + replace(start, end, replacement) + } +} + +@Composable +fun rememberCurrencyOutputTransformation( + currencyCode: String, + locale: KurrencyLocale = KurrencyLocale.current(), + fractionDigits: Int = CurrencyFormatter.getFractionDigitsOrDefault(currencyCode).coerceAtLeast(0), +): CurrencyOutputTransformation = remember(currencyCode, locale, fractionDigits) { + CurrencyOutputTransformation(currencyCode, locale, fractionDigits) +} diff --git a/kurrency-compose/src/commonTest/kotlin/org/kimplify/kurrency/compose/CurrencyOutputTransformationTest.kt b/kurrency-compose/src/commonTest/kotlin/org/kimplify/kurrency/compose/CurrencyOutputTransformationTest.kt new file mode 100644 index 0000000..3746e88 --- /dev/null +++ b/kurrency-compose/src/commonTest/kotlin/org/kimplify/kurrency/compose/CurrencyOutputTransformationTest.kt @@ -0,0 +1,123 @@ +package org.kimplify.kurrency.compose + +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.ui.text.AnnotatedString +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import org.kimplify.kurrency.KurrencyLocale + +class CurrencyOutputTransformationTest { + + @Test + fun formatsMinorUnitDigits() { + val examples = listOf( + "" to "$0.00", + "0" to "$0.00", + "00" to "$0.00", + "1" to "$0.01", + "12" to "$0.12", + "123" to "$1.23", + "1234" to "$12.34", + "123456" to "$1,234.56", + "0001" to "$0.01", + "0001234" to "$12.34", + ) + + for ((raw, expected) in examples) { + assertEquals(expected, transform(raw), "Raw input: $raw") + } + } + + @Test + fun filtersNonDigitsFromDisplay() { + val examples = listOf( + "abc" to "$0.00", + "a1b2" to "$0.12", + "1.23" to "$1.23", + "12.3" to "$1.23", + "-1234" to "$12.34", + "1e3" to "$0.13", + " 1,234.56 " to "$1,234.56", + ) + + for ((raw, expected) in examples) { + assertEquals(expected, transform(raw), "Raw input: $raw") + } + } + + @Test + fun customScaleChangesInputUnitsRatherThanDisplayPrecision() { + assertEquals("$1,234.00", transform("1234", fractionDigits = 0)) + assertEquals("$123.40", transform("1234", fractionDigits = 1)) + assertEquals("$12.34", transform("1234", fractionDigits = 2)) + assertEquals("$1.23", transform("1234", fractionDigits = 3)) + assertEquals("$10.00", transform("9999", fractionDigits = 3)) + } + + @Test + fun preservesLegacyOutputAcrossCurrenciesAndLocales() { + val configurations = listOf( + "USD" to KurrencyLocale.US, + "EUR" to KurrencyLocale.GERMANY, + "EUR" to KurrencyLocale.FRANCE, + "JPY" to KurrencyLocale.JAPAN, + "KWD" to KurrencyLocale.US, + "INR" to KurrencyLocale.INDIA, + "SAR" to KurrencyLocale.SAUDI_ARABIA, + ) + val inputs = listOf("", "1", "1234", "123456789", "0001234", "a1b2", "١٢", "۱۲") + + for ((currencyCode, locale) in configurations) { + for (raw in inputs) { + val expected = CurrencyVisualTransformation(currencyCode, locale) + .filter(AnnotatedString(raw)).text.text + assertEquals(expected, transform(raw, currencyCode, locale), "$currencyCode ${locale.languageTag}: $raw") + } + } + } + + @Test + fun invalidCurrencyFallsBackToSynthesizedAmount() { + assertEquals("12.34", transform("1234", currencyCode = "INVALID")) + assertEquals("0.00", transform("", currencyCode = "INVALID")) + assertEquals("0.001", transform("1", currencyCode = "INVALID", fractionDigits = 3)) + } + + @Test + fun failedAmountFormattingUsesInputScaleDespiteSuccessfulZeroFormatting() { + assertEquals("9".repeat(397) + ".999", transform("9".repeat(400), fractionDigits = 3)) + } + + @Test + fun unicodeDigitsRetainExistingFormatterFallback() { + assertEquals("0.١٢", transform("١٢")) + assertEquals("0.۱۲", transform("۱۲")) + assertEquals("$0.12", transform("😀1😀2")) + } + + @Test + fun rejectsNegativeInputScale() { + assertFailsWith { + CurrencyOutputTransformation("USD", KurrencyLocale.US, fractionDigits = -1) + } + } + + private fun transform( + raw: String, + currencyCode: String = "USD", + locale: KurrencyLocale = KurrencyLocale.US, + fractionDigits: Int? = null, + ): String { + val transformation = if (fractionDigits == null) { + CurrencyOutputTransformation(currencyCode, locale) + } else { + CurrencyOutputTransformation(currencyCode, locale, fractionDigits) + } + val state = TextFieldState(raw) + state.edit { + with(transformation) { transformOutput() } + } + return state.text.toString() + } +} diff --git a/kurrency-compose/src/commonTest/kotlin/org/kimplify/kurrency/compose/CurrencyVisualTransformationTest.kt b/kurrency-compose/src/commonTest/kotlin/org/kimplify/kurrency/compose/CurrencyVisualTransformationTest.kt new file mode 100644 index 0000000..ed88a77 --- /dev/null +++ b/kurrency-compose/src/commonTest/kotlin/org/kimplify/kurrency/compose/CurrencyVisualTransformationTest.kt @@ -0,0 +1,32 @@ +package org.kimplify.kurrency.compose + +import androidx.compose.ui.text.AnnotatedString +import kotlin.test.Test +import kotlin.test.assertEquals +import org.kimplify.kurrency.KurrencyLocale + +class CurrencyVisualTransformationTest { + + @Test + fun preservesExistingMinorUnitFormatting() { + val transformation = CurrencyVisualTransformation("USD", KurrencyLocale.US) + val examples = listOf( + "" to "$0.00", + "1" to "$0.01", + "1234" to "$12.34", + "0001234" to "$12.34", + "a1b2" to "$0.12", + ) + + for ((raw, expected) in examples) { + assertEquals(expected, transformation.filter(AnnotatedString(raw)).text.text) + } + } + + @Test + fun customScaleKeepsCurrencyDisplayPrecision() { + val transformation = CurrencyVisualTransformation("USD", KurrencyLocale.US, fractionDigits = 3) + + assertEquals("$1.23", transformation.filter(AnnotatedString("1234")).text.text) + } +} diff --git a/kurrency-compose/src/jvmTest/kotlin/org/kimplify/kurrency/compose/CurrencyOutputTransformationJvmTest.kt b/kurrency-compose/src/jvmTest/kotlin/org/kimplify/kurrency/compose/CurrencyOutputTransformationJvmTest.kt new file mode 100644 index 0000000..7d797fa --- /dev/null +++ b/kurrency-compose/src/jvmTest/kotlin/org/kimplify/kurrency/compose/CurrencyOutputTransformationJvmTest.kt @@ -0,0 +1,23 @@ +package org.kimplify.kurrency.compose + +import androidx.compose.foundation.text.input.TextFieldState +import kotlin.test.Test +import kotlin.test.assertEquals +import org.kimplify.kurrency.CurrencyFormatter +import org.kimplify.kurrency.KurrencyLocale + +class CurrencyOutputTransformationJvmTest { + + @Test + fun pseudoCurrencyUsesZeroInputScaleWhenMetadataIsNegative() { + val state = TextFieldState("1234") + val transformation = CurrencyOutputTransformation("XDR", KurrencyLocale.US) + + state.edit { + with(transformation) { transformOutput() } + } + + val expected = CurrencyFormatter(KurrencyLocale.US).formatCurrencyStyle("1234", "XDR") + assertEquals(expected, state.text.toString()) + } +} diff --git a/kurrency-compose/src/jvmTest/kotlin/org/kimplify/kurrency/compose/CurrencyOutputTransformationUiTest.kt b/kurrency-compose/src/jvmTest/kotlin/org/kimplify/kurrency/compose/CurrencyOutputTransformationUiTest.kt new file mode 100644 index 0000000..f70db0e --- /dev/null +++ b/kurrency-compose/src/jvmTest/kotlin/org/kimplify/kurrency/compose/CurrencyOutputTransformationUiTest.kt @@ -0,0 +1,375 @@ +package org.kimplify.kurrency.compose + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performKeyInput +import androidx.compose.ui.test.performTextInput +import androidx.compose.ui.test.performTextInputSelection +import androidx.compose.ui.test.pressKey +import androidx.compose.ui.test.runComposeUiTest +import androidx.compose.ui.text.TextRange +import org.kimplify.kurrency.KurrencyLocale +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotSame +import kotlin.test.assertSame + +@OptIn(ExperimentalTestApi::class) +class CurrencyOutputTransformationUiTest { + + @Test + fun formatting_displaysMinorUnitsWithoutEditingState() = runComposeUiTest { + val state = TextFieldState("123456", TextRange(2, 5)) + + setContent { + BasicTextField( + state = state, + modifier = Modifier.testTag("amount"), + outputTransformation = rememberCurrencyOutputTransformation("USD", KurrencyLocale.US) + ) + } + + onNodeWithTag("amount").assertTextEquals("$1,234.56") + runOnIdle { + assertEquals("123456", state.text.toString()) + assertEquals(TextRange(2, 5), state.selection) + } + } + + @Test + fun emptyState_displaysZeroWithoutInsertingDigits() = runComposeUiTest { + val state = TextFieldState() + + setContent { + BasicTextField( + state = state, + modifier = Modifier.testTag("amount"), + outputTransformation = rememberCurrencyOutputTransformation("USD", KurrencyLocale.US) + ) + } + + onNodeWithTag("amount").assertTextEquals("$0.00") + runOnIdle { + assertEquals("", state.text.toString()) + assertEquals(TextRange.Zero, state.selection) + } + } + + @Test + fun insertionAtDisplayedInteriorOffset_editsCorrespondingRawDigit() = runComposeUiTest { + val state = TextFieldState("123456") + + setContent { + BasicTextField( + state = state, + modifier = Modifier.testTag("amount"), + outputTransformation = rememberCurrencyOutputTransformation("USD", KurrencyLocale.US) + ) + } + + val field = onNodeWithTag("amount") + field.assertTextEquals("$1,234.56") + field.performTextInputSelection(TextRange(5), relativeToOriginalText = false) + runOnIdle { assertEquals(TextRange(3), state.selection) } + field.performTextInput("9") + + field.assertTextEquals("$12,394.56") + runOnIdle { + assertEquals("1239456", state.text.toString()) + assertEquals(TextRange(4), state.selection) + } + } + + @Test + fun eitherSideOfDisplayedDecimal_mapsToSameRawBoundary() = runComposeUiTest { + val beforeDecimal = TextFieldState("123456") + val afterDecimal = TextFieldState("123456") + + setContent { + val transformation = rememberCurrencyOutputTransformation("USD", KurrencyLocale.US) + Column { + BasicTextField( + state = beforeDecimal, + modifier = Modifier.testTag("before"), + outputTransformation = transformation + ) + BasicTextField( + state = afterDecimal, + modifier = Modifier.testTag("after"), + outputTransformation = transformation + ) + } + } + + val before = onNodeWithTag("before") + val after = onNodeWithTag("after") + before.performTextInputSelection(TextRange(6), relativeToOriginalText = false) + runOnIdle { assertEquals(TextRange(4), beforeDecimal.selection) } + before.performTextInput("9") + after.performTextInputSelection(TextRange(7), relativeToOriginalText = false) + runOnIdle { assertEquals(TextRange(4), afterDecimal.selection) } + after.performTextInput("9") + + before.assertTextEquals("$12,349.56") + after.assertTextEquals("$12,349.56") + runOnIdle { + assertEquals("1234956", beforeDecimal.text.toString()) + assertEquals("1234956", afterDecimal.text.toString()) + } + } + + @Test + fun replacingDisplayedRangeAcrossSeparators_replacesOnlySelectedRawDigits() = runComposeUiTest { + val state = TextFieldState("123456") + + setContent { + BasicTextField( + state = state, + modifier = Modifier.testTag("amount"), + outputTransformation = rememberCurrencyOutputTransformation("USD", KurrencyLocale.US) + ) + } + + val field = onNodeWithTag("amount") + field.performTextInputSelection(TextRange(2, 8), relativeToOriginalText = false) + runOnIdle { assertEquals(TextRange(1, 5), state.selection) } + field.performTextInput("9") + + field.assertTextEquals("$1.96") + runOnIdle { + assertEquals("196", state.text.toString()) + assertEquals(TextRange(2), state.selection) + } + } + + @Test + fun backspaceAfterDisplayedDecimal_deletesPreviousRawDigit() = runComposeUiTest { + val state = TextFieldState("123456") + + setContent { + BasicTextField( + state = state, + modifier = Modifier.testTag("amount"), + outputTransformation = rememberCurrencyOutputTransformation("USD", KurrencyLocale.US) + ) + } + + val field = onNodeWithTag("amount") + field.performTextInputSelection(TextRange(7), relativeToOriginalText = false) + field.performKeyInput { pressKey(Key.Backspace) } + + field.assertTextEquals("$123.56") + runOnIdle { + assertEquals("12356", state.text.toString()) + assertEquals(TextRange(3), state.selection) + } + } + + @Test + fun deleteAfterCrossingDisplayedDecimal_deletesNextRawDigit() = runComposeUiTest { + val state = TextFieldState("123456") + + setContent { + BasicTextField( + state = state, + modifier = Modifier.testTag("amount"), + outputTransformation = rememberCurrencyOutputTransformation("USD", KurrencyLocale.US) + ) + } + + val field = onNodeWithTag("amount") + field.performTextInputSelection(TextRange(6), relativeToOriginalText = false) + field.performKeyInput { + pressKey(Key.DirectionRight) + pressKey(Key.Delete) + } + + field.assertTextEquals("$123.46") + runOnIdle { + assertEquals("12346", state.text.toString()) + assertEquals(TextRange(4), state.selection) + } + } + + @Test + fun deleteAtDisplayedDigitBoundary_deletesNextRawDigit() = runComposeUiTest { + val state = TextFieldState("123456") + + setContent { + BasicTextField( + state = state, + modifier = Modifier.testTag("amount"), + outputTransformation = rememberCurrencyOutputTransformation("USD", KurrencyLocale.US), + ) + } + + val field = onNodeWithTag("amount") + field.performTextInputSelection(TextRange(5), relativeToOriginalText = false) + field.performKeyInput { pressKey(Key.Delete) } + + field.assertTextEquals("$123.56") + runOnIdle { + assertEquals("12356", state.text.toString()) + assertEquals(TextRange(3), state.selection) + } + } + + @Test + fun shortRawAmount_displayPaddingDoesNotBecomeInput() = runComposeUiTest { + val state = TextFieldState("5") + + setContent { + BasicTextField( + state = state, + modifier = Modifier.testTag("amount"), + outputTransformation = rememberCurrencyOutputTransformation("USD", KurrencyLocale.US) + ) + } + + val field = onNodeWithTag("amount") + field.assertTextEquals("$0.05") + runOnIdle { assertEquals("5", state.text.toString()) } + field.performTextInputSelection(TextRange(4), relativeToOriginalText = false) + runOnIdle { assertEquals(TextRange.Zero, state.selection) } + field.performTextInput("9") + + field.assertTextEquals("$0.95") + runOnIdle { assertEquals("95", state.text.toString()) } + } + + @Test + fun leadingRawZeros_remainInStateAndKeepVisibleDigitsEditable() = runComposeUiTest { + val state = TextFieldState("001234") + + setContent { + BasicTextField( + state = state, + modifier = Modifier.testTag("amount"), + outputTransformation = rememberCurrencyOutputTransformation("USD", KurrencyLocale.US) + ) + } + + val field = onNodeWithTag("amount") + field.assertTextEquals("$12.34") + runOnIdle { assertEquals("001234", state.text.toString()) } + field.performTextInputSelection(TextRange(2), relativeToOriginalText = false) + runOnIdle { assertEquals(TextRange(3), state.selection) } + field.performTextInput("9") + + field.assertTextEquals("$192.34") + runOnIdle { assertEquals("0019234", state.text.toString()) } + } + + @Test + fun zeroScale_keepsIntegerZerosMappedBeforeDisplayedFraction() = runComposeUiTest { + val state = TextFieldState("100") + + setContent { + BasicTextField( + state = state, + modifier = Modifier.testTag("amount"), + outputTransformation = rememberCurrencyOutputTransformation( + "USD", KurrencyLocale.US, fractionDigits = 0 + ) + ) + } + + val field = onNodeWithTag("amount") + field.assertTextEquals("$100.00") + field.performTextInputSelection(TextRange(3), relativeToOriginalText = false) + runOnIdle { assertEquals(TextRange(2), state.selection) } + field.performTextInput("9") + + field.assertTextEquals("$1,090.00") + runOnIdle { assertEquals("1090", state.text.toString()) } + } + + @Test + fun sharedTransformation_andRecompositionKeepFieldStatesIndependent() = runComposeUiTest { + val firstState = TextFieldState("1234") + val secondState = TextFieldState("5678", TextRange(2)) + var recomposition by mutableStateOf(0) + var observedRecomposition = -1 + + setContent { + observedRecomposition = recomposition + val transformation = rememberCurrencyOutputTransformation("USD", KurrencyLocale.US) + Column { + BasicTextField( + state = firstState, + modifier = Modifier.testTag("first"), + outputTransformation = transformation + ) + BasicTextField( + state = secondState, + modifier = Modifier.testTag("second"), + outputTransformation = transformation + ) + } + } + + val first = onNodeWithTag("first") + val second = onNodeWithTag("second") + first.assertTextEquals("$12.34") + second.assertTextEquals("$56.78") + first.performTextInputSelection(TextRange(2), relativeToOriginalText = false) + first.performTextInput("9") + runOnIdle { recomposition++ } + + first.assertTextEquals("$192.34") + second.assertTextEquals("$56.78") + runOnIdle { + assertEquals(1, observedRecomposition) + assertEquals("19234", firstState.text.toString()) + assertEquals(TextRange(2), firstState.selection) + assertEquals("5678", secondState.text.toString()) + assertEquals(TextRange(2), secondState.selection) + } + } + + @Test + fun rememberHelper_reusesEqualKeysAndRecreatesForEachChangedKey() = runComposeUiTest { + var currencyCode by mutableStateOf("USD") + var locale by mutableStateOf(KurrencyLocale.US) + var fractionDigits by mutableStateOf(2) + var recomposition by mutableStateOf(0) + var observedRecomposition = -1 + var remembered: CurrencyOutputTransformation? = null + + setContent { + observedRecomposition = recomposition + remembered = rememberCurrencyOutputTransformation(currencyCode, locale, fractionDigits) + } + + waitForIdle() + val initial = remembered + runOnIdle { recomposition++ } + waitForIdle() + assertEquals(1, observedRecomposition) + assertSame(initial, remembered) + + runOnIdle { currencyCode = "EUR" } + waitForIdle() + val changedCurrency = remembered + assertNotSame(initial, changedCurrency) + + runOnIdle { locale = KurrencyLocale.GERMANY } + waitForIdle() + val changedLocale = remembered + assertNotSame(changedCurrency, changedLocale) + + runOnIdle { fractionDigits = 3 } + waitForIdle() + assertNotSame(changedLocale, remembered) + } +} diff --git a/sample/androidApp/build.gradle.kts b/sample/androidApp/build.gradle.kts index 77bf547..6018335 100644 --- a/sample/androidApp/build.gradle.kts +++ b/sample/androidApp/build.gradle.kts @@ -13,6 +13,7 @@ android { applicationId = "org.kimplify.sample" versionCode = 1 versionName = "1.0.0" + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } buildFeatures { @@ -25,4 +26,7 @@ dependencies { implementation(libs.androidx.activity.compose) debugImplementation("androidx.compose.ui:ui-tooling:1.9.0-alpha04") implementation("androidx.compose.ui:ui-tooling-preview:1.9.0-alpha04") + androidTestImplementation("androidx.compose.ui:ui-test-junit4:1.11.2") + androidTestImplementation(libs.androidx.runner) + androidTestImplementation(libs.androidx.testExt.junit) } diff --git a/sample/androidApp/src/androidTest/kotlin/org/kimplify/sample/CurrencyInputTest.kt b/sample/androidApp/src/androidTest/kotlin/org/kimplify/sample/CurrencyInputTest.kt new file mode 100644 index 0000000..8db0658 --- /dev/null +++ b/sample/androidApp/src/androidTest/kotlin/org/kimplify/sample/CurrencyInputTest.kt @@ -0,0 +1,133 @@ +package org.kimplify.sample + +import android.graphics.Bitmap +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.SemanticsNodeInteraction +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performKeyInput +import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.test.performTextClearance +import androidx.compose.ui.test.performTextInput +import androidx.compose.ui.test.performTextInputSelection +import androidx.compose.ui.test.pressKey +import androidx.compose.ui.text.TextRange +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import java.io.File +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestName +import org.junit.runner.RunWith + +@OptIn(ExperimentalTestApi::class) +@RunWith(AndroidJUnit4::class) +class CurrencyInputTest { + + @get:Rule + val composeRule = createAndroidComposeRule() + + @get:Rule + val testName = TestName() + + @Before + fun focusCurrencyInput() { + field().performScrollTo().performClick() + } + + @After + fun captureScreen() { + val instrumentation = InstrumentationRegistry.getInstrumentation() + val directory = File(requireNotNull( + InstrumentationRegistry.getArguments().getString("additionalTestOutputDir") + )) + directory.mkdirs() + val screenshot = requireNotNull(instrumentation.uiAutomation.takeScreenshot()) + try { + File(directory, "${testName.methodName}.png").outputStream().use { + check(screenshot.compress(Bitmap.CompressFormat.PNG, 100, it)) + } + } finally { + screenshot.recycle() + } + } + + @Test + fun initialAmountDisplaysCurrencyAndKeepsMinorUnits() { + assertAmount("$12.34", "1234") + } + + @Test + fun enteringAndClearingDigitsKeepsZeroDisplaySeparateFromStoredText() { + field().performTextClearance() + assertAmount("$0.00", "") + + field().performTextInput("123456") + assertAmount("$1,234.56", "123456") + + field().performTextClearance() + assertAmount("$0.00", "") + + field().performTextInput("0") + assertAmount("$0.00", "0") + } + + @Test + fun insertionAtDisplayedInteriorOffsetEditsCorrespondingRawDigit() { + enterGroupedAmount() + + field().performTextInputSelection(TextRange(5), relativeToOriginalText = false) + field().performTextInput("9") + + assertAmount("$12,394.56", "1239456") + } + + @Test + fun replacingDisplayedSelectionAcrossSeparatorsReplacesSelectedRawDigits() { + enterGroupedAmount() + + field().performTextInputSelection(TextRange(2, 8), relativeToOriginalText = false) + field().performTextInput("9") + + assertAmount("$1.96", "196") + } + + @Test + fun backspaceAfterDisplayedDecimalDeletesPreviousRawDigit() { + enterGroupedAmount() + + field().performTextInputSelection(TextRange(7), relativeToOriginalText = false) + field().performKeyInput { pressKey(Key.Backspace) } + + assertAmount("$123.56", "12356") + } + + @Test + fun deleteAtDisplayedDigitBoundaryDeletesNextRawDigit() { + enterGroupedAmount() + + field().performTextInputSelection(TextRange(5), relativeToOriginalText = false) + field().performKeyInput { pressKey(Key.Delete) } + + assertAmount("$123.56", "12356") + } + + private fun enterGroupedAmount() { + field().performTextClearance() + field().performTextInput("123456") + assertAmount("$1,234.56", "123456") + } + + private fun field(): SemanticsNodeInteraction = composeRule.onNodeWithTag("currencyInput") + + private fun assertAmount(displayedAmount: String, rawAmount: String) { + field().assertTextEquals(displayedAmount) + composeRule.onNodeWithTag("currencyRawAmount") + .assertTextEquals("Stored minor units: $rawAmount") + } +} diff --git a/sample/shared/src/commonMain/kotlin/org/kimplify/kurrency/sample/KurrencySample.kt b/sample/shared/src/commonMain/kotlin/org/kimplify/kurrency/sample/KurrencySample.kt index 425f01d..3311add 100644 --- a/sample/shared/src/commonMain/kotlin/org/kimplify/kurrency/sample/KurrencySample.kt +++ b/sample/shared/src/commonMain/kotlin/org/kimplify/kurrency/sample/KurrencySample.kt @@ -2,6 +2,7 @@ package org.kimplify.kurrency.sample +import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -13,6 +14,11 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.InputTransformation +import androidx.compose.foundation.text.input.TextFieldLineLimits +import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults @@ -33,13 +39,16 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import org.kimplify.kurrency.CurrencyStyle import org.kimplify.kurrency.ExperimentalKurrency import org.kimplify.kurrency.Kurrency import org.kimplify.kurrency.KurrencyLocale import org.kimplify.kurrency.compose.current +import org.kimplify.kurrency.compose.rememberCurrencyOutputTransformation import org.kimplify.kurrency.compose.rememberCurrencyState @Composable @@ -62,6 +71,7 @@ fun KurrencySampleApp() { LocaleSeparatorsExample() CurrencyStateExample() MultiCurrencyExample() + CurrencyInputExample() InteractiveCurrencyConverter() CurrencyListSection() } @@ -323,6 +333,50 @@ private fun MultiCurrencyExample() { } } +@Composable +private fun CurrencyInputExample() { + SampleCard(title = "Currency Input") { + val state = rememberTextFieldState(initialText = "1234") + val outputTransformation = rememberCurrencyOutputTransformation("USD") + + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text( + text = "Amount (USD)", + style = MaterialTheme.typography.labelLarge + ) + + BasicTextField( + state = state, + outputTransformation = outputTransformation, + inputTransformation = InputTransformation { + if (asCharSequence().any { it !in '0'..'9' }) revertAllChanges() + }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + lineLimits = TextFieldLineLimits.SingleLine, + textStyle = MaterialTheme.typography.titleLarge.copy( + color = MaterialTheme.colorScheme.onSurface + ), + modifier = Modifier + .testTag("currencyInput") + .fillMaxWidth() + .border(1.dp, MaterialTheme.colorScheme.outline, RoundedCornerShape(16.dp)) + .padding(16.dp) + ) + + Text( + text = "Stored minor units: ${state.text}", + modifier = Modifier.testTag("currencyRawAmount"), + style = MaterialTheme.typography.bodyMedium + ) + Text( + text = "Type USD cents. Clearing keeps the stored text empty and displays zero.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} + @Composable private fun InteractiveCurrencyConverter() { SampleCard(title = "Interactive Converter") {