diff --git a/README.md b/README.md index 92ce2db..2482953 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,12 @@ distinguish between cellular vs WiFi connection. ![badge][badge-android] ![badge][badge-ios] +[currency](currency/README.md): Format currency values. +![badge][badge-android] +![badge][badge-ios] +![badge][badge-js] +![badge][badge-jvm] + [ignore-ios](ignore-ios/README.md): Annotations to ignore iOS tests. ![badge][badge-android] ![badge][badge-ios] @@ -54,7 +60,7 @@ language translations, unit/integration tests are welcomed. [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](LICENSE) -Copyright 2021 Appmattus Limited +Copyright 2021-2025 Appmattus Limited Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the diff --git a/currency/README.md b/currency/README.md new file mode 100644 index 0000000..6e1f101 --- /dev/null +++ b/currency/README.md @@ -0,0 +1,35 @@ +# currency + +A Kotlin Multiplatform Mobile library to format currency values. + +## Getting started + +![badge][badge-android] +![badge][badge-ios] +![badge][badge-js] +[![Maven Central](https://img.shields.io/maven-central/v/com.appmattus.mpu/currency)](https://search.maven.org/search?q=g:com.appmattus.mpu) + +Include the following dependency in your *build.gradle.kts* file: + +```kotlin +commonMain { + implementation("com.appmattus.mpu:currency:") +} +``` + +Format a currency value: + +```kotlin +Currency.format(value = 1345.23, currencyCode = "GBP", locale = "en-GB") +``` + +[badge-android]: http://img.shields.io/badge/platform-android-6EDB8D.svg?style=flat +[badge-ios]: http://img.shields.io/badge/platform-ios-CDCDCD.svg?style=flat +[badge-js]: http://img.shields.io/badge/platform-js-F8DB5D.svg?style=flat +[badge-jvm]: http://img.shields.io/badge/platform-jvm-DB413D.svg?style=flat +[badge-linux]: http://img.shields.io/badge/platform-linux-2D3F6C.svg?style=flat +[badge-windows]: http://img.shields.io/badge/platform-windows-4D76CD.svg?style=flat +[badge-mac]: http://img.shields.io/badge/platform-macos-111111.svg?style=flat +[badge-watchos]: http://img.shields.io/badge/platform-watchos-C0C0C0.svg?style=flat +[badge-tvos]: http://img.shields.io/badge/platform-tvos-808080.svg?style=flat +[badge-wasm]: https://img.shields.io/badge/platform-wasm-624FE8.svg?style=flat diff --git a/currency/build.gradle.kts b/currency/build.gradle.kts new file mode 100644 index 0000000..31f58c9 --- /dev/null +++ b/currency/build.gradle.kts @@ -0,0 +1,85 @@ +/* + * Copyright 2025 Appmattus Limited + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + id("com.android.library") + kotlin("multiplatform") + alias(libs.plugins.gradleMavenPublishPlugin) + alias(libs.plugins.dokkaPlugin) +} + +kotlin { + androidTarget() + + iosX64() + iosArm64() + iosSimulatorArm64() + + js { + browser() + nodejs() + } + + listOf( + iosX64(), + iosArm64(), + iosSimulatorArm64() + ).forEach { iosTarget -> + iosTarget.binaries.framework { + baseName = "multiplatformutils-currency" + isStatic = true + } + } + + // Apply the default hierarchy again. It'll create, for example, the iosMain source set: + applyDefaultHierarchyTemplate() + + sourceSets { + commonMain.dependencies { + implementation(libs.kotlinx.coroutines) + } + commonTest.dependencies { + implementation(kotlin("test")) + } + androidUnitTest.dependencies { + implementation(libs.robolectric) + } + } + + compilerOptions { + jvmToolchain(11) + freeCompilerArgs.add("-Xexpect-actual-classes") + } +} + +android { + namespace = "com.appmattus.multiplatformutils.currency" + compileSdk = 35 + + defaultConfig { + minSdk = 21 + } + buildTypes { + getByName("release") { + isMinifyEnabled = false + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } +} diff --git a/currency/gradle.properties b/currency/gradle.properties new file mode 100644 index 0000000..fa7c9ca --- /dev/null +++ b/currency/gradle.properties @@ -0,0 +1,20 @@ +# +# Copyright 2025 Appmattus Limited +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +POM_NAME=com.appmattus.mpu:currency +POM_ARTIFACT_ID=currency +POM_DESCRIPTION=Format currencies +POM_INCEPTION_YEAR=2025 diff --git a/currency/src/androidMain/kotlin/com/appmattus/currency/Currency.kt b/currency/src/androidMain/kotlin/com/appmattus/currency/Currency.kt new file mode 100644 index 0000000..4862587 --- /dev/null +++ b/currency/src/androidMain/kotlin/com/appmattus/currency/Currency.kt @@ -0,0 +1,52 @@ +/* + * Copyright 2025 Appmattus Limited + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.appmattus.currency + +import java.text.NumberFormat +import java.util.Currency +import java.util.Locale + +actual object Currency { + actual fun format( + amount: Double, + currencyCode: String, + locale: String, + showFractionDigits: Boolean, + roundingMode: RoundingMode + ): String { + return NumberFormat.getCurrencyInstance(Locale.forLanguageTag(locale)).apply { + val currency = Currency.getInstance(currencyCode) + this.currency = currency + this.roundingMode = when (roundingMode) { + RoundingMode.Up -> java.math.RoundingMode.UP + RoundingMode.Down -> java.math.RoundingMode.DOWN + RoundingMode.Ceiling -> java.math.RoundingMode.CEILING + RoundingMode.Floor -> java.math.RoundingMode.FLOOR + RoundingMode.HalfUp -> java.math.RoundingMode.HALF_UP + RoundingMode.HalfDown -> java.math.RoundingMode.HALF_DOWN + RoundingMode.HalfEven -> java.math.RoundingMode.HALF_EVEN + } + + // When showing decimal places use value from currency as NumberFormat defaults to device locale + // TND is formatted with 2 dp and not 3 dp and JPY with 2 dp and not 0 dp + (if (!showFractionDigits) 0 else currency.defaultFractionDigits).let { decimalPlaces -> + minimumFractionDigits = decimalPlaces + maximumFractionDigits = decimalPlaces + } + }.format(amount) + } +} diff --git a/currency/src/androidUnitTest/kotlin/com/appmattus/currency/RobolectricTest.android.kt b/currency/src/androidUnitTest/kotlin/com/appmattus/currency/RobolectricTest.android.kt new file mode 100644 index 0000000..df50ad3 --- /dev/null +++ b/currency/src/androidUnitTest/kotlin/com/appmattus/currency/RobolectricTest.android.kt @@ -0,0 +1,24 @@ +/* + * Copyright 2025 Appmattus Limited + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.appmattus.currency + +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") +@RunWith(RobolectricTestRunner::class) +actual abstract class RobolectricTest actual constructor() diff --git a/currency/src/commonMain/kotlin/com/appmattus/currency/Currency.kt b/currency/src/commonMain/kotlin/com/appmattus/currency/Currency.kt new file mode 100644 index 0000000..9ac7c1d --- /dev/null +++ b/currency/src/commonMain/kotlin/com/appmattus/currency/Currency.kt @@ -0,0 +1,37 @@ +/* + * Copyright 2025 Appmattus Limited + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.appmattus.currency + +expect object Currency { + + /** + * Format a currency [amount] taking [locale] into account. Decimals are shown based on [showFractionDigits] and numbers are rounded based + * on [roundingMode]. + * @param amount Currency amount to format + * @param currencyCode ISO-4217 alphabetic currency code, i.e. CAD + * @param locale IETF BCP 47 language tag, i.e. en-CA + * @param showFractionDigits `true` to show fraction digits, `false` otherwise + * @param roundingMode [RoundingMode] to use + */ + fun format( + amount: Double, + currencyCode: String, + locale: String, + showFractionDigits: Boolean = true, + roundingMode: RoundingMode = RoundingMode.HalfEven + ): String +} diff --git a/currency/src/commonMain/kotlin/com/appmattus/currency/RoundingMode.kt b/currency/src/commonMain/kotlin/com/appmattus/currency/RoundingMode.kt new file mode 100644 index 0000000..8f47afa --- /dev/null +++ b/currency/src/commonMain/kotlin/com/appmattus/currency/RoundingMode.kt @@ -0,0 +1,173 @@ +/* + * Copyright 2025 Appmattus Limited + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.appmattus.currency + +enum class RoundingMode { + /** + * Rounding mode to round away from zero. Always increments the digit prior to a non-zero discarded fraction. Note that this rounding mode + * never decreases the magnitude of the calculated value. + * + * Example: + * + * | Input Number | Input rounded with [Up] rounding | + * | :--- | :--- | + * | 5.5 | 6 | + * | 2.5 | 3 | + * | 1.6 | 2 | + * | 1.1 | 2 | + * | 1.0 | 1 | + * | -1.0 | -1 | + * | -1.1 | -2 | + * | -1.6 | -2 | + * | -2.5 | -3 | + * | -5.5 | -6 | + */ + Up, + + /** + * Rounding mode to round towards zero. Never increments the digit prior to a discarded fraction (i.e., truncates). Note that this rounding + * mode never increases the magnitude of the calculated value. This mode corresponds to the IEEE 754-2019 rounding-direction attribute + * `roundTowardZero`. + * + * Example: + * + * | Input Number | Input rounded with [Down] rounding | + * | :--- | :--- | + * | 5.5 | 5 | + * | 2.5 | 2 | + * | 1.6 | 1 | + * | 1.1 | 1 | + * | 1.0 | 1 | + * | -1.0 | -1 | + * | -1.1 | -1 | + * | -1.6 | -1 | + * | -2.5 | -2 | + * | -5.5 | -5 | + */ + Down, + + /** + * Rounding mode to round towards positive infinity. If the result is positive, behaves as for [Up]; if negative, behaves as for [Down]. Note + * that this rounding mode never decreases the calculated value. This mode corresponds to the IEEE 754-2019 rounding-direction attribute + * `roundTowardPositive`. + * + * Example: + * + * | Input Number | Input rounded with [Ceiling] rounding | + * | :--- | :--- | + * | 5.5 | 6 | + * | 2.5 | 3 | + * | 1.6 | 2 | + * | 1.1 | 2 | + * | 1.0 | 1 | + * | -1.0 | -1 | + * | -1.1 | -1 | + * | -1.6 | -1 | + * | -2.5 | -2 | + * | -5.5 | -5 | + */ + Ceiling, + + /** + * Rounding mode to round towards negative infinity. If the result is positive, behave as for [Down]; if negative, behave as for [Up]. Note + * that this rounding mode never increases the calculated value. This mode corresponds to the IEEE 754-2019 rounding-direction attribute + * `roundTowardNegative`. + * + * Example: + * + * | Input Number | Input rounded with [Floor] rounding | + * | :--- | :--- | + * | 5.5 | 5 | + * | 2.5 | 2 | + * | 1.6 | 1 | + * | 1.1 | 1 | + * | 1.0 | 1 | + * | -1.0 | -1 | + * | -1.1 | -2 | + * | -1.6 | -2 | + * | -2.5 | -3 | + * | -5.5 | -6 | + */ + Floor, + + /** + * Rounding mode to round towards "nearest neighbor" unless both neighbors are equidistant, in which case round up. Behaves as for [Up] if + * the discarded fraction is 0.5; otherwise, behaves as for [Down]. Note that this is the rounding mode commonly taught at school. This mode + * corresponds to the IEEE 754-2019 rounding-direction attribute `roundTiesToAway`. + * + * Example: + * + * | Input Number | Input rounded with [HalfUp] rounding | + * | :--- | :--- | + * | 5.5 | 6 | + * | 2.5 | 3 | + * | 1.6 | 2 | + * | 1.1 | 1 | + * | 1.0 | 1 | + * | -1.0 | -1 | + * | -1.1 | -1 | + * | -1.6 | -2 | + * | -2.5 | -3 | + * | -5.5 | -6 | + */ + HalfUp, + + /** + * Rounding mode to round towards "nearest neighbor" unless both neighbors are equidistant, in which case round down. Behaves as for [Up] if + * the discarded fraction is > 0.5; otherwise, behaves as for [Down]. + * + * Example: + * + * | Input Number | Input rounded with [HalfDown] rounding | + * | :--- | :--- | + * | 5.5 | 5 | + * | 2.5 | 2 | + * | 1.6 | 2 | + * | 1.1 | 1 | + * | 1.0 | 1 | + * | -1.0 | -1 | + * | -1.1 | -1 | + * | -1.6 | -2 | + * | -2.5 | -2 | + * | -5.5 | -5 | + */ + HalfDown, + + /** + * Rounding mode to round towards the "nearest neighbor" unless both neighbors are equidistant, in which case, round towards the even + * neighbor. Behaves as for [HalfUp] if the digit to the left of the discarded fraction is odd; behaves as for [HalfDown] if it's even. Note + * that this is the rounding mode that statistically minimizes cumulative error when applied repeatedly over a sequence of calculations. It + * is sometimes known as "Banker's rounding," and is chiefly used in the USA. This rounding mode is analogous to the rounding policy used for + * `float` and `double` arithmetic in Java. This mode corresponds to the IEEE 754-2019 rounding-direction attribute `roundTiesToEven`. + * + * Example: + * + * | Input Number | Input rounded with [HalfEven] rounding | + * | :--- | :--- | + * | 5.5 | 6 | + * | 2.5 | 2 | + * | 1.6 | 2 | + * | 1.1 | 1 | + * | 1.0 | 1 | + * | -1.0 | -1 | + * | -1.1 | -1 | + * | -1.6 | -2 | + * | -2.5 | -2 | + * | -5.5 | -6 | + */ + HalfEven +} diff --git a/currency/src/commonTest/kotlin/com/appmattus/currency/CurrencyTest.kt b/currency/src/commonTest/kotlin/com/appmattus/currency/CurrencyTest.kt new file mode 100644 index 0000000..514a039 --- /dev/null +++ b/currency/src/commonTest/kotlin/com/appmattus/currency/CurrencyTest.kt @@ -0,0 +1,318 @@ +/* + * Copyright 2025 Appmattus Limited + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.appmattus.currency + +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals + +@Suppress("TooManyFunctions") +class CurrencyTest : RobolectricTest() { + + @Test + fun testEnGb() { + val actual = listOf("GBP", "EUR", "USD", "CAD", "MXN", "JPY").map { currencyCode -> + Currency.format(amount = 1345.23, currencyCode = currencyCode, locale = "en-GB") + } + assertContentEquals( + listOf("£1,345.23", "€1,345.23", "US$1,345.23", "CA$1,345.23", "MX$1,345.23", "JP¥1,345"), + actual + ) + } + + @Test + fun testEnGbWithoutDecimals() { + val actual = listOf("GBP", "EUR", "USD", "CAD", "MXN", "JPY").map { currencyCode -> + Currency.format(amount = 1345.23, currencyCode = currencyCode, locale = "en-GB", showFractionDigits = false) + } + + assertContentEquals(listOf("£1,345", "€1,345", "US$1,345", "CA$1,345", "MX$1,345", "JP¥1,345"), actual) + } + + @Test + fun testEnUs() { + val actual = listOf("GBP", "EUR", "USD", "CAD", "MXN", "JPY").map { currencyCode -> + Currency.format(amount = 1345.23, currencyCode = currencyCode, locale = "en-US") + } + + assertContentEquals( + listOf("£1,345.23", "€1,345.23", "$1,345.23", "CA$1,345.23", "MX$1,345.23", "¥1,345"), + actual + ) + } + + @Test + fun testEnUsWithoutDecimals() { + val actual = listOf("GBP", "EUR", "USD", "CAD", "MXN", "JPY").map { currencyCode -> + Currency.format(amount = 1345.23, currencyCode = currencyCode, locale = "en-US", showFractionDigits = false) + } + + assertContentEquals(listOf("£1,345", "€1,345", "$1,345", "CA$1,345", "MX$1,345", "¥1,345"), actual) + } + + @Test + fun testEnCa() { + val actual = listOf("GBP", "EUR", "USD", "CAD", "MXN", "JPY").map { currencyCode -> + Currency.format(amount = 1345.23, currencyCode = currencyCode, locale = "en-CA") + } + + assertContentEquals( + listOf("£1,345.23", "€1,345.23", "US$1,345.23", "$1,345.23", "MX$1,345.23", "JP¥1,345"), + actual + ) + } + + @Test + fun testEnCaWithoutDecimals() { + val actual = listOf("GBP", "EUR", "USD", "CAD", "MXN", "JPY").map { currencyCode -> + Currency.format(amount = 1345.23, currencyCode = currencyCode, locale = "en-CA", showFractionDigits = false) + } + + assertContentEquals(listOf("£1,345", "€1,345", "US$1,345", "$1,345", "MX$1,345", "JP¥1,345"), actual) + } + + @Test + fun testFrCa() { + val actual = listOf("GBP", "EUR", "USD", "CAD", "MXN", "JPY").map { currencyCode -> + Currency.format(amount = 1345.23, currencyCode = currencyCode, locale = "fr-CA") + } + + assertContentEquals( + listOf("1 345,23 £", "1 345,23 €", "1 345,23 $ US", "1 345,23 $", "1 345,23 MXN", "1 345 ¥"), + actual + ) + } + + @Test + fun testFrCaWithoutDecimals() { + val actual = listOf("GBP", "EUR", "USD", "CAD", "MXN", "JPY").map { currencyCode -> + Currency.format(amount = 1345.23, currencyCode = currencyCode, locale = "fr-CA", showFractionDigits = false) + } + + assertContentEquals(listOf("1 345 £", "1 345 €", "1 345 $ US", "1 345 $", "1 345 MXN", "1 345 ¥"), actual) + } + + @Test + fun testDeDe() { + val actual = listOf("GBP", "EUR", "USD", "CAD", "MXN", "JPY").map { currencyCode -> + Currency.format(amount = 1345.23, currencyCode = currencyCode, locale = "de-DE") + } + + assertContentEquals( + listOf("1.345,23 £", "1.345,23 €", "1.345,23 $", "1.345,23 CA$", "1.345,23 MX\$", "1.345 ¥"), + actual + ) + } + + @Test + fun testDeDeWithoutDecimals() { + val actual = listOf("GBP", "EUR", "USD", "CAD", "MXN", "JPY").map { currencyCode -> + Currency.format(amount = 1345.23, currencyCode = currencyCode, locale = "de-DE", showFractionDigits = false) + } + + assertContentEquals(listOf("1.345 £", "1.345 €", "1.345 $", "1.345 CA$", "1.345 MX\$", "1.345 ¥"), actual) + } + + @Test + fun testFrFr() { + val actual = listOf("GBP", "EUR", "USD", "CAD", "MXN", "JPY").map { currencyCode -> + Currency.format(amount = 1345.23, currencyCode = currencyCode, locale = "fr-FR").replace(" ", " ") + } + + assertContentEquals( + listOf("1 345,23 £GB", "1 345,23 €", "1 345,23 \$US", "1 345,23 \$CA", "1 345,23 \$MX", "1 345 JPY"), + actual + ) + } + + @Test + fun testFrFrWithoutDecimals() { + val actual = listOf("GBP", "EUR", "USD", "CAD", "MXN", "JPY").map { currencyCode -> + Currency.format( + amount = 1345.23, + currencyCode = currencyCode, + locale = "fr-FR", + showFractionDigits = false + ).replace(" ", " ") + } + + assertContentEquals( + listOf("1 345 £GB", "1 345 €", "1 345 \$US", "1 345 \$CA", "1 345 \$MX", "1 345 JPY"), + actual + ) + } + + @Test + fun testTnd() { + val actual = listOf("en-GB", "en-US", "en-CA", "fr-CA", "de-DE", "fr-FR").map { locale -> + Currency.format( + amount = 1345.23, + currencyCode = "TND", + locale = locale + ).replace("TND ", "TND").replace(" ", " ") + } + + assertContentEquals( + listOf("TND1,345.230", "TND1,345.230", "TND1,345.230", "1 345,230 TND", "1.345,230 TND", "1 345,230 TND"), + actual + ) + } + + @Test + fun testTndWithoutDecimals() { + val actual = listOf("en-GB", "en-US", "en-CA", "fr-CA", "de-DE", "fr-FR").map { locale -> + Currency.format(amount = 1345.23, currencyCode = "TND", locale = locale, showFractionDigits = false) + .replace("TND ", "TND") + .replace(" ", " ") + } + + assertContentEquals(listOf("TND1,345", "TND1,345", "TND1,345", "1 345 TND", "1.345 TND", "1 345 TND"), actual) + } + + @Test + fun testRoundingModeUp() { + mapOf( + 5.5 to 6, + 2.5 to 3, + 1.6 to 2, + 1.1 to 2, + 1.0 to 1, + -1.0 to -1, + -1.1 to -2, + -1.6 to -2, + -2.5 to -3, + -5.5 to -6 + ).forEach { (value, expected) -> + val actual = Currency.format(value, "JPY", "en-US", roundingMode = RoundingMode.Up) + assertEquals(expected.toString(), actual.replace("¥", "")) + } + } + + @Test + fun testRoundingModeDown() { + mapOf( + 5.5 to 5, + 2.5 to 2, + 1.6 to 1, + 1.1 to 1, + 1.0 to 1, + -1.0 to -1, + -1.1 to -1, + -1.6 to -1, + -2.5 to -2, + -5.5 to -5 + ).forEach { (value, expected) -> + val actual = Currency.format(value, "JPY", "en-US", roundingMode = RoundingMode.Down) + assertEquals(expected.toString(), actual.replace("¥", "")) + } + } + + @Test + fun testRoundingModeCeiling() { + mapOf( + 5.5 to 6, + 2.5 to 3, + 1.6 to 2, + 1.1 to 2, + 1.0 to 1, + -1.0 to -1, + -1.1 to -1, + -1.6 to -1, + -2.5 to -2, + -5.5 to -5 + ).forEach { (value, expected) -> + val actual = Currency.format(value, "JPY", "en-US", roundingMode = RoundingMode.Ceiling) + assertEquals(expected.toString(), actual.replace("¥", "")) + } + } + + @Test + fun testRoundingModeFloor() { + mapOf( + 5.5 to 5, + 2.5 to 2, + 1.6 to 1, + 1.1 to 1, + 1.0 to 1, + -1.0 to -1, + -1.1 to -2, + -1.6 to -2, + -2.5 to -3, + -5.5 to -6 + ).forEach { (value, expected) -> + val actual = Currency.format(value, "JPY", "en-US", roundingMode = RoundingMode.Floor) + assertEquals(expected.toString(), actual.replace("¥", "")) + } + } + + @Test + fun testRoundingModeHalfUp() { + mapOf( + 5.5 to 6, + 2.5 to 3, + 1.6 to 2, + 1.1 to 1, + 1.0 to 1, + -1.0 to -1, + -1.1 to -1, + -1.6 to -2, + -2.5 to -3, + -5.5 to -6 + ).forEach { (value, expected) -> + val actual = Currency.format(value, "JPY", "en-US", roundingMode = RoundingMode.HalfUp) + assertEquals(expected.toString(), actual.replace("¥", "")) + } + } + + @Test + fun testRoundingModeHalfDown() { + mapOf( + 5.5 to 5, + 2.5 to 2, + 1.6 to 2, + 1.1 to 1, + 1.0 to 1, + -1.0 to -1, + -1.1 to -1, + -1.6 to -2, + -2.5 to -2, + -5.5 to -5 + ).forEach { (value, expected) -> + val actual = Currency.format(value, "JPY", "en-US", roundingMode = RoundingMode.HalfDown) + assertEquals(expected.toString(), actual.replace("¥", "")) + } + } + + @Test + fun testRoundingModeHalfEven() { + mapOf( + 5.5 to 6, + 2.5 to 2, + 1.6 to 2, + 1.1 to 1, + 1.0 to 1, + -1.0 to -1, + -1.1 to -1, + -1.6 to -2, + -2.5 to -2, + -5.5 to -6 + ).forEach { (value, expected) -> + val actual = Currency.format(value, "JPY", "en-US", roundingMode = RoundingMode.HalfEven) + assertEquals(expected.toString(), actual.replace("¥", "")) + } + } +} diff --git a/currency/src/commonTest/kotlin/com/appmattus/currency/RobolectricTest.kt b/currency/src/commonTest/kotlin/com/appmattus/currency/RobolectricTest.kt new file mode 100644 index 0000000..08b5808 --- /dev/null +++ b/currency/src/commonTest/kotlin/com/appmattus/currency/RobolectricTest.kt @@ -0,0 +1,20 @@ +/* + * Copyright 2025 Appmattus Limited + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.appmattus.currency + +@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") +expect abstract class RobolectricTest() diff --git a/currency/src/iosMain/kotlin/com/appmattus/currency/Currency.kt b/currency/src/iosMain/kotlin/com/appmattus/currency/Currency.kt new file mode 100644 index 0000000..d50056f --- /dev/null +++ b/currency/src/iosMain/kotlin/com/appmattus/currency/Currency.kt @@ -0,0 +1,53 @@ +/* + * Copyright 2025 Appmattus Limited + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.appmattus.currency + +import platform.Foundation.NSLocale +import platform.Foundation.NSNumber +import platform.Foundation.NSNumberFormatter +import platform.Foundation.NSNumberFormatterCurrencyStyle +import platform.Foundation.NSNumberFormatterRoundCeiling +import platform.Foundation.NSNumberFormatterRoundDown +import platform.Foundation.NSNumberFormatterRoundFloor +import platform.Foundation.NSNumberFormatterRoundHalfDown +import platform.Foundation.NSNumberFormatterRoundHalfEven +import platform.Foundation.NSNumberFormatterRoundHalfUp +import platform.Foundation.NSNumberFormatterRoundUp + +actual object Currency { + + actual fun format(amount: Double, currencyCode: String, locale: String, showFractionDigits: Boolean, roundingMode: RoundingMode): String { + val formatter = NSNumberFormatter() + formatter.numberStyle = NSNumberFormatterCurrencyStyle + formatter.locale = NSLocale(localeIdentifier = locale) + formatter.currencyCode = currencyCode + formatter.roundingMode = when (roundingMode) { + RoundingMode.Up -> NSNumberFormatterRoundUp + RoundingMode.Down -> NSNumberFormatterRoundDown + RoundingMode.Ceiling -> NSNumberFormatterRoundCeiling + RoundingMode.Floor -> NSNumberFormatterRoundFloor + RoundingMode.HalfUp -> NSNumberFormatterRoundHalfUp + RoundingMode.HalfDown -> NSNumberFormatterRoundHalfDown + RoundingMode.HalfEven -> NSNumberFormatterRoundHalfEven + } + if (!showFractionDigits) { + formatter.minimumFractionDigits = 0uL + formatter.maximumFractionDigits = 0uL + } + return formatter.stringFromNumber(NSNumber(double = amount)).orEmpty() + } +} diff --git a/currency/src/iosTest/kotlin/com/appmattus/currency/RobolectricTest.ios.kt b/currency/src/iosTest/kotlin/com/appmattus/currency/RobolectricTest.ios.kt new file mode 100644 index 0000000..1d25f79 --- /dev/null +++ b/currency/src/iosTest/kotlin/com/appmattus/currency/RobolectricTest.ios.kt @@ -0,0 +1,20 @@ +/* + * Copyright 2025 Appmattus Limited + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.appmattus.currency + +@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") +actual abstract class RobolectricTest actual constructor() diff --git a/currency/src/jsMain/kotlin/com/appmattus/currency/Currency.kt b/currency/src/jsMain/kotlin/com/appmattus/currency/Currency.kt new file mode 100644 index 0000000..fe0d337 --- /dev/null +++ b/currency/src/jsMain/kotlin/com/appmattus/currency/Currency.kt @@ -0,0 +1,67 @@ +/* + * Copyright 2025 Appmattus Limited + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.appmattus.currency + +actual object Currency { + + actual fun format(amount: Double, currencyCode: String, locale: String, showFractionDigits: Boolean, roundingMode: RoundingMode): String { + val formatter = Intl.NumberFormat( + locale, + numberFormatOptions { + style = "currency" + currency = currencyCode + + this.roundingMode = when (roundingMode) { + RoundingMode.Up -> "expand" + RoundingMode.Down -> "trunc" + RoundingMode.Ceiling -> "ceil" + RoundingMode.Floor -> "floor" + RoundingMode.HalfUp -> "halfExpand" + RoundingMode.HalfDown -> "halfTrunc" + RoundingMode.HalfEven -> "halfEven" + } + + if (!showFractionDigits) { + minimumFractionDigits = 0 + maximumFractionDigits = 0 + } + } + ) + + return formatter.format(amount) + } +} + +private fun numberFormatOptions(builder: NumberFormatOptions.() -> Unit): NumberFormatOptions { + val options = js("{}").unsafeCast() + builder(options) + return options +} + +private external class Intl { + class NumberFormat(locale: String = definedExternally, options: NumberFormatOptions = definedExternally) { + fun format(value: Double): String + } +} + +private external interface NumberFormatOptions { + var style: String? + var currency: String? + var roundingMode: String? + var minimumFractionDigits: Int? + var maximumFractionDigits: Int? +} diff --git a/currency/src/jsTest/kotlin/com/appmattus/currency/RobolectricTest.js.kt b/currency/src/jsTest/kotlin/com/appmattus/currency/RobolectricTest.js.kt new file mode 100644 index 0000000..1d25f79 --- /dev/null +++ b/currency/src/jsTest/kotlin/com/appmattus/currency/RobolectricTest.js.kt @@ -0,0 +1,20 @@ +/* + * Copyright 2025 Appmattus Limited + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.appmattus.currency + +@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") +actual abstract class RobolectricTest actual constructor() diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d3349d8..31ec30d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -29,6 +29,7 @@ kotlinx_coroutines = "1.10.2" leakCanary = "2.14" orbitMvi = "9.0.0" +robolectric = "4.14.1" [libraries] @@ -61,6 +62,8 @@ leakcanary_plumber = { module = "com.squareup.leakcanary:plumber-android", versi orbitViewmodel = { module = "org.orbit-mvi:orbit-viewmodel", version.ref = "orbitMvi" } +robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } + buildscript_android = { module = "com.android.tools.build:gradle", version.ref = "androidGradlePlugin" } buildscript_kotlin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } buildscript_hilt = { module = "com.google.dagger:hilt-android-gradle-plugin", version.ref = "google_dagger" } diff --git a/settings.gradle.kts b/settings.gradle.kts index f351603..b2006a4 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,5 +1,6 @@ include(":battery") include(":connectivity") +include(":currency") include(":ignore-ios") include(":ignore-junit") include(":ignore-junit5")