Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions .github/workflows/android-sample.yml
Original file line number Diff line number Diff line change
@@ -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
10 changes: 6 additions & 4 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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:
Expand Down
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
64 changes: 64 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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

Expand Down
1 change: 0 additions & 1 deletion kurrency-compose/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,6 @@ kotlin {

js(IR) {
browser()
nodejs()
}

listOf(
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
Loading
Loading