diff --git a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/model/JettonSummary.kt b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/model/JettonSummary.kt index e17bff29..41760858 100644 --- a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/model/JettonSummary.kt +++ b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/model/JettonSummary.kt @@ -22,8 +22,8 @@ package io.ton.walletkit.demo.presentation.model import io.ton.walletkit.api.generated.TONJetton -import java.math.BigDecimal -import java.math.RoundingMode +import io.ton.walletkit.model.TONTokenAmount +import io.ton.walletkit.model.TONTokenAmountFormatter /** * UI-friendly jetton summary model for list display. @@ -49,16 +49,11 @@ data class JettonSummary( get() = imageUrl ?: imageData companion object { - fun formatBalance(rawBalance: String, decimals: Int?, symbol: String): String = try { - val d = decimals ?: 9 - val divisor = BigDecimal.TEN.pow(d) - val formatted = BigDecimal(rawBalance) - .divide(divisor, d, RoundingMode.DOWN) - .stripTrailingZeros() - .toPlainString() - "$formatted $symbol" - } catch (e: Exception) { - "$rawBalance $symbol (raw)" + fun formatBalance(rawBalance: String, decimals: Int?, symbol: String): String { + val amount = TONTokenAmount.parseOrNull(rawBalance)?.let { nano -> + TONTokenAmountFormatter().apply { nanoUnitDecimalsNumber = decimals ?: 9 }.string(nano) + } ?: return "$rawBalance $symbol (raw)" + return "$amount $symbol" } fun from(jetton: TONJetton): JettonSummary { diff --git a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/components/wallet/home/AnimatedBalance.kt b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/components/wallet/home/AnimatedBalance.kt new file mode 100644 index 00000000..ec210f73 --- /dev/null +++ b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/components/wallet/home/AnimatedBalance.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2025 TonTech + * + * 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. + */ +package io.ton.walletkit.demo.presentation.ui.components.wallet.home + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.Easing +import androidx.compose.animation.core.tween +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import java.math.BigDecimal +import java.math.RoundingMode + +private const val COUNT_UP_DURATION_MS = 500 + +// Ease-out cubic — fast start, smooth deceleration into the target (mirrors the TS demo wallet). +private val EaseOutCubic = Easing { t -> 1f - (1f - t) * (1f - t) * (1f - t) } + +/** + * "Casino" count-up: returns a value that eases from the previously-shown number to [target] + * whenever [target] changes (e.g. a balance update). Double-precise, so large jetton amounts + * keep their value. The initial value is shown immediately — only later updates animate. + */ +@Composable +fun rememberCountUp(target: Double, durationMillis: Int = COUNT_UP_DURATION_MS): Double { + val progress = remember { Animatable(1f) } + var start by remember { mutableStateOf(target) } + var end by remember { mutableStateOf(target) } + + LaunchedEffect(target) { + if (target != end) { + start = start + (end - start) * progress.value // capture the currently-shown value + end = target + progress.snapTo(0f) + progress.animateTo(1f, tween(durationMillis, easing = EaseOutCubic)) + } + } + return start + (end - start) * progress.value +} + +/** Formats a count-up value, trailing zeros stripped and capped at [maxFractionDigits]. */ +fun formatCountUp(value: Double, maxFractionDigits: Int): String = BigDecimal.valueOf(value) + .setScale(maxFractionDigits, RoundingMode.DOWN) + .stripTrailingZeros() + .toPlainString() diff --git a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/components/wallet/home/WalletHomeAssetRow.kt b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/components/wallet/home/WalletHomeAssetRow.kt index 195c52d5..c594a472 100644 --- a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/components/wallet/home/WalletHomeAssetRow.kt +++ b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/components/wallet/home/WalletHomeAssetRow.kt @@ -61,10 +61,12 @@ data class WalletHomeAssetItem( val name: String, val symbol: String, val formattedAmount: String, + val amountValue: Double, val icon: WalletHomeAssetIcon, ) private val IconSize = 48.dp +private const val AMOUNT_FRACTION_DIGITS = 5 @Composable fun WalletHomeAssetRow( @@ -87,8 +89,9 @@ fun WalletHomeAssetRow( modifier = Modifier.weight(1f), ) + val animatedAmount = rememberCountUp(item.amountValue) TonText( - text = item.formattedAmount, + text = "${formatCountUp(animatedAmount, AMOUNT_FRACTION_DIGITS)} ${item.symbol}", style = TonTheme.typography.bodySemibold, color = TonTheme.colors.textPrimary, maxLines = 1, diff --git a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/components/wallet/home/WalletHomeBalance.kt b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/components/wallet/home/WalletHomeBalance.kt index d9c3ace5..ed2a0592 100644 --- a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/components/wallet/home/WalletHomeBalance.kt +++ b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/components/wallet/home/WalletHomeBalance.kt @@ -40,11 +40,18 @@ import io.ton.walletkit.demo.presentation.util.TestTags // as iOS `lastTextBaseline` HStack. @Composable fun WalletHomeBalance( - totalBalanceInteger: String, - totalBalanceFraction: String, + totalBalance: Double, + balanceSuffix: String, + maxFractionDigits: Int, modifier: Modifier = Modifier, onSecretTap: (() -> Unit)? = null, ) { + val animated = rememberCountUp(totalBalance) + val formatted = formatCountUp(animated, maxFractionDigits) + val dotIndex = formatted.indexOf('.') + val integerPart = if (dotIndex < 0) formatted else formatted.substring(0, dotIndex) + val fractionPart = if (dotIndex < 0) "" else formatted.substring(dotIndex) + val gestureModifier = if (onSecretTap != null) { Modifier.devToggleTaps(onTrigger = onSecretTap) } else { @@ -70,15 +77,15 @@ fun WalletHomeBalance( // alignment aligns bounding-box bottoms, not baselines. Row { TonText( - text = totalBalanceInteger, + text = integerPart, style = TonTheme.typography.price64, color = TonTheme.colors.textPrimary, maxLines = 1, modifier = Modifier.alignByBaseline(), ) - if (totalBalanceFraction.isNotEmpty()) { + if (fractionPart.isNotEmpty()) { TonText( - text = totalBalanceFraction, + text = fractionPart, style = TonTheme.typography.price40, color = TonTheme.colors.textPrimary, maxLines = 1, @@ -86,7 +93,7 @@ fun WalletHomeBalance( ) } TonText( - text = " TON", + text = balanceSuffix, style = TonTheme.typography.price40, color = TonTheme.colors.textPrimary, maxLines = 1, diff --git a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/components/wallet/home/WalletHomeContent.kt b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/components/wallet/home/WalletHomeContent.kt index f1615f0b..288c6612 100644 --- a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/components/wallet/home/WalletHomeContent.kt +++ b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/components/wallet/home/WalletHomeContent.kt @@ -46,8 +46,9 @@ import io.ton.walletkit.demo.designsystem.theme.TonTheme // MAX_NFTS = 5 carousel cards. @Composable fun WalletHomeContent( - totalBalanceInteger: String, - totalBalanceFraction: String, + totalBalance: Double, + balanceSuffix: String, + balanceMaxFractionDigits: Int, assets: List, nfts: List, hasMoreAssets: Boolean, @@ -70,8 +71,9 @@ fun WalletHomeContent( verticalArrangement = Arrangement.spacedBy(24.dp), ) { WalletHomeBalance( - totalBalanceInteger = totalBalanceInteger, - totalBalanceFraction = totalBalanceFraction, + totalBalance = totalBalance, + balanceSuffix = balanceSuffix, + maxFractionDigits = balanceMaxFractionDigits, modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp) diff --git a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/WalletScreen.kt b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/WalletScreen.kt index 96658342..467385c0 100644 --- a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/WalletScreen.kt +++ b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/WalletScreen.kt @@ -111,6 +111,7 @@ import io.ton.walletkit.demo.presentation.ui.sheet.TransactionRequestSheet import io.ton.walletkit.demo.presentation.ui.sheet.TransferJettonSheet import io.ton.walletkit.demo.presentation.ui.sheet.WalletDetailsSheet import io.ton.walletkit.demo.presentation.ui.sheet.WalletsBottomSheet +import io.ton.walletkit.demo.presentation.util.JettonFormatters import io.ton.walletkit.demo.presentation.viewmodel.NFTsListViewModel import io.ton.walletkit.demo.presentation.viewmodel.SwapViewModel @@ -142,16 +143,6 @@ private fun trimFraction(value: String?, maxFractionDigits: Int): String { return truncated } -private fun splitBalance(rawBalance: String?, maxFractionDigits: Int): Pair { - val trimmed = trimFraction(rawBalance, maxFractionDigits) - val dotIndex = trimmed.indexOf('.') - return if (dotIndex < 0) { - trimmed to "" - } else { - trimmed.substring(0, dotIndex) to trimmed.substring(dotIndex) - } -} - private fun buildAssetList( rawBalance: String?, jettons: List, @@ -164,11 +155,17 @@ private fun buildAssetList( name = "Toncoin", symbol = "TON", formattedAmount = "$tonAmount TON", + amountValue = rawBalance?.toDoubleOrNull() ?: 0.0, icon = WalletHomeAssetIcon.Ton, ) val items = mutableListOf(tonItem) jettons.take(maxAssets - 1).forEach { jetton -> - val amount = trimFraction(jetton.balance, maxFractionDigits) + // jetton.balance is raw (smallest units); format with the token's decimals for display. + val amount = JettonFormatters.formatBalance( + jetton.balance, + jetton.jetton.decimalsNumber ?: 9, + maxDecimals = maxFractionDigits, + ) val icon = jetton.imageUrl?.takeIf { it.isNotBlank() } ?.let { WalletHomeAssetIcon.Url(it) } ?: WalletHomeAssetIcon.Placeholder(jetton.symbol) @@ -177,6 +174,7 @@ private fun buildAssetList( name = jetton.name, symbol = jetton.symbol, formattedAmount = "$amount ${jetton.symbol}", + amountValue = amount.toDoubleOrNull() ?: 0.0, icon = icon, ) } @@ -406,8 +404,8 @@ fun WalletScreen( LaunchedEffect(nftsViewModel) { nftsViewModel?.loadNFTs() } - val (totalInteger, totalFraction) = remember(activeWallet?.balance) { - splitBalance(activeWallet?.balance, MAX_FRACTION_DIGITS) + val totalBalance = remember(activeWallet?.balance) { + activeWallet?.balance?.toDoubleOrNull() ?: 0.0 } val assetItems = remember(activeWallet?.balance, state.jettons) { buildAssetList(activeWallet?.balance, state.jettons, MAX_FRACTION_DIGITS, MAX_ASSETS) @@ -514,8 +512,9 @@ fun WalletScreen( } WalletHomeContent( - totalBalanceInteger = totalInteger, - totalBalanceFraction = totalFraction, + totalBalance = totalBalance, + balanceSuffix = " TON", + balanceMaxFractionDigits = MAX_FRACTION_DIGITS, assets = assetItems, nfts = nftPreviews, hasMoreAssets = hasMoreAssets, diff --git a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/util/JettonFormatters.kt b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/util/JettonFormatters.kt index a01a412e..88605b81 100644 --- a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/util/JettonFormatters.kt +++ b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/util/JettonFormatters.kt @@ -21,6 +21,8 @@ */ package io.ton.walletkit.demo.presentation.util +import io.ton.walletkit.model.TONTokenAmount +import io.ton.walletkit.model.TONTokenAmountFormatter import java.math.BigDecimal import java.math.RoundingMode @@ -37,19 +39,20 @@ object JettonFormatters { * @param maxDecimals Maximum decimals to display (default: 4) * @return Formatted balance string */ - fun formatBalance(balance: String, decimals: Int, maxDecimals: Int = 4): String = try { - val balanceBigInt = BigDecimal(balance) - val divisor = BigDecimal.TEN.pow(decimals) - val formattedValue = balanceBigInt.divide(divisor, decimals, RoundingMode.DOWN) - - // Limit displayed decimals - val displayDecimals = minOf(decimals, maxDecimals) - val scaledValue = formattedValue.setScale(displayDecimals, RoundingMode.DOWN) - - // Remove trailing zeros - scaledValue.stripTrailingZeros().toPlainString() - } catch (e: Exception) { - balance + fun formatBalance(balance: String, decimals: Int, maxDecimals: Int = 4): String { + // SDK formatter for the raw→decimal conversion, then cap fraction digits to [maxDecimals]. + val nano = TONTokenAmount.parseOrNull(balance) ?: return balance + val formatted = TONTokenAmountFormatter() + .apply { nanoUnitDecimalsNumber = decimals } + .string(nano) ?: return balance + return try { + BigDecimal(formatted) + .setScale(minOf(decimals, maxDecimals), RoundingMode.DOWN) + .stripTrailingZeros() + .toPlainString() + } catch (e: Exception) { + formatted + } } /** diff --git a/AndroidDemo/app/src/main/res/values/themes.xml b/AndroidDemo/app/src/main/res/values/themes.xml index b221cddd..f4289b16 100644 --- a/AndroidDemo/app/src/main/res/values/themes.xml +++ b/AndroidDemo/app/src/main/res/values/themes.xml @@ -1,6 +1,9 @@ - diff --git a/AndroidDemo/designsystem/src/main/java/io/ton/walletkit/demo/designsystem/theme/TonTheme.kt b/AndroidDemo/designsystem/src/main/java/io/ton/walletkit/demo/designsystem/theme/TonTheme.kt index 70a0e9d4..5366eb8e 100644 --- a/AndroidDemo/designsystem/src/main/java/io/ton/walletkit/demo/designsystem/theme/TonTheme.kt +++ b/AndroidDemo/designsystem/src/main/java/io/ton/walletkit/demo/designsystem/theme/TonTheme.kt @@ -23,10 +23,12 @@ package io.ton.walletkit.demo.designsystem.theme import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Color import io.ton.walletkit.demo.designsystem.tokens.TonColors import io.ton.walletkit.demo.designsystem.tokens.TonTypography import io.ton.walletkit.demo.designsystem.tokens.darkTonColors @@ -41,6 +43,21 @@ private val LocalTonTypography = staticCompositionLocalOf { error("TonTypography not provided. Wrap your UI in TonTheme { ... }") } +// Fallback scheme for call sites still on Material defaults: the app is always white, so pin every +// surface to white (else an unset Scaffold/TopAppBar containerColor paints Material's #FFFBFE). +private val WhiteMaterialColors = lightColorScheme( + background = Color.White, + surface = Color.White, + surfaceVariant = Color.White, + surfaceBright = Color.White, + surfaceDim = Color.White, + surfaceContainerLowest = Color.White, + surfaceContainerLow = Color.White, + surfaceContainer = Color.White, + surfaceContainerHigh = Color.White, + surfaceContainerHighest = Color.White, +) + @Composable fun TonTheme( darkTheme: Boolean = isSystemInDarkTheme(), @@ -52,8 +69,8 @@ fun TonTheme( LocalTonColors provides colors, LocalTonTypography provides typography, ) { - // Compose call sites that haven't migrated yet still see a sane MaterialTheme. - MaterialTheme(content = content) + // Compose call sites that haven't migrated yet still see a sane (all-white) MaterialTheme. + MaterialTheme(colorScheme = WhiteMaterialColors, content = content) } } diff --git a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/model/TONTokenAmountFormatter.kt b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/model/TONTokenAmountFormatter.kt new file mode 100644 index 00000000..dd2d9e2f --- /dev/null +++ b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/model/TONTokenAmountFormatter.kt @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2025 TonTech + * + * 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. + */ +package io.ton.walletkit.model + +import java.math.BigInteger + +/** Converts a [TONTokenAmount] between nano units and a decimal string. */ +open class TONTokenAmountFormatter { + + // Defaults to 9, as TON uses nano units (1 TON = 10^9 nanoTON) + var nanoUnitDecimalsNumber: Int = 9 + + var allowFractionalTrailingZeroes: Boolean = false + + fun string(from: TONTokenAmount): String? { + if (nanoUnitDecimalsNumber < 0) return null + + var digits = from.nanoUnits.toString() + val negative = digits.startsWith("-") + if (negative) digits = digits.substring(1) + + if (digits.length < nanoUnitDecimalsNumber) { + digits = "0".repeat(nanoUnitDecimalsNumber - digits.length) + digits + } + + val splitIndex = digits.length - nanoUnitDecimalsNumber + val integer = if (splitIndex > 0) digits.substring(0, splitIndex) else "" + var fraction = digits.takeLast(nanoUnitDecimalsNumber) + + if (!allowFractionalTrailingZeroes) { + fraction = fraction.trimEnd('0') + } + + val negativePrefix = if (negative) "-" else "" + val integerPart = integer.ifEmpty { "0" } + val fractionPart = if (fraction.isEmpty()) "" else ".$fraction" + + return "$negativePrefix$integerPart$fractionPart" + } + + fun amount(from: String): TONTokenAmount? { + val clean = from.trim() + if (clean.isEmpty()) return null + + val parts = clean.split(".") + if (parts.size > 2) return null + + val integerPart = parts[0] + val fractionalPart = if (parts.size == 2) parts[1] else "" + + val integerValue = integerPart.toBigIntegerOrNull() ?: return null + // Sign must come from the raw string: "-0".toBigInteger() is zero, so signum() can't see it + val negative = integerPart.startsWith("-") + var result = integerValue.abs() * BigInteger.TEN.pow(nanoUnitDecimalsNumber) + + if (fractionalPart.isNotEmpty()) { + if (fractionalPart.any { it !in '0'..'9' }) return null + val normalized = if (fractionalPart.length > nanoUnitDecimalsNumber) { + fractionalPart.substring(0, nanoUnitDecimalsNumber) + } else { + fractionalPart.padEnd(nanoUnitDecimalsNumber, '0') + } + val fractionValue = normalized.toBigIntegerOrNull() ?: return null + result += fractionValue + } + + if (negative) result = result.negate() + return TONTokenAmount(result) + } +}