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
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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()
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -70,23 +77,23 @@ 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,
modifier = Modifier.alignByBaseline(),
)
}
TonText(
text = " TON",
text = balanceSuffix,
style = TonTheme.typography.price40,
color = TonTheme.colors.textPrimary,
maxLines = 1,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<WalletHomeAssetItem>,
nfts: List<WalletHomeNFTPreview>,
hasMoreAssets: Boolean,
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -142,16 +143,6 @@ private fun trimFraction(value: String?, maxFractionDigits: Int): String {
return truncated
}

private fun splitBalance(rawBalance: String?, maxFractionDigits: Int): Pair<String, String> {
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<JettonSummary>,
Expand All @@ -164,11 +155,17 @@ private fun buildAssetList(
name = "Toncoin",
symbol = "TON",
formattedAmount = "$tonAmount TON",
amountValue = rawBalance?.toDoubleOrNull() ?: 0.0,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/WalletScreen.kt"

# Show surrounding code for the cited line ranges (158, 177, 407-409)
sed -n '130,200p' "$FILE" | cat -n
echo "----"
sed -n '360,460p' "$FILE" | cat -n

# Find all occurrences of toDoubleOrNull() in this file
echo "---- toDoubleOrNull occurrences ----"
rg -n "toDoubleOrNull\(\)\s*\?\:\s*0\.0" "$FILE" || true
echo "---- any toDoubleOrNull ----"
rg -n "toDoubleOrNull\(\)" "$FILE" || true

# Find amountValue / total balance variable declarations and usage
echo "---- amountValue usage ----"
rg -n "\bamountValue\b" "$FILE" || true
echo "---- rawBalance usage ----"
rg -n "\brawBalance\b" "$FILE" || true
echo "---- total balance usage (search for 'total' near balance or bigdecimal) ----"
rg -n "total.*balance|balance.*total|BigDecimal\.valueOf|valueOf\(" "$FILE" || true

Repository: ton-org/kit-android

Length of output: 8898


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/WalletScreen.kt"

echo "---- WalletHomeAssetItem definition ----"
fd "WalletHomeAssetItem" AndroidDemo/app/src/main/java/io/ton/walletkit/demo -t f | head -n 20
# if multiple, show the most likely Kotlin file
for f in $(fd "WalletHomeAssetItem" AndroidDemo/app/src/main/java/io/ton/walletkit/demo -t f | head -n 10); do
  echo "==== $f ===="
  rg -n "data class WalletHomeAssetItem|class WalletHomeAssetItem|WalletHomeAssetItem\(" "$f" || true
done

echo "---- usages of amountValue ----"
rg -n "\bamountValue\b" AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation -S || true

echo "---- usages of totalBalance ----"
rg -n "\btotalBalance\b" AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation -S || true

echo "---- in WalletScreen.kt: where totalBalance is used ----"
rg -n "\btotalBalance\b" "$FILE" -n || true

echo "---- search for BigDecimal.valueOf usage nearby ----"
rg -n "BigDecimal\.valueOf|valueOf\(" AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation -S || true

Repository: ton-org/kit-android

Length of output: 3069


🏁 Script executed:

#!/bin/bash
set -euo pipefail

BASE="AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui"

echo "---- WalletHomeAssetRow.kt (amountValue usage + rememberCountUp) ----"
FILE1="$BASE/components/wallet/home/WalletHomeAssetRow.kt"
sed -n '1,140p' "$FILE1" | cat -n

echo "---- WalletHomeBalance.kt (totalBalance usage + rememberCountUp) ----"
FILE2="$BASE/components/wallet/home/WalletHomeBalance.kt"
sed -n '1,120p' "$FILE2" | cat -n

echo "---- AnimatedBalance.kt (formatCountUp + BigDecimal.valueOf) ----"
FILE3="$BASE/components/wallet/home/AnimatedBalance.kt"
sed -n '1,140p' "$FILE3" | cat -n

echo "---- find rememberCountUp definition ----"
fd "rememberCountUp" AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation -t f | head -n 20
for f in $(fd "rememberCountUp" AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation -t f | head -n 20); do
  echo "==== $f ===="
  rg -n "fun\s+rememberCountUp|rememberCountUp\(" "$f" || true
done

Repository: ton-org/kit-android

Length of output: 15274


Avoid converting balance strings to Double with lossy precision and silent 0.0 fallback.

WalletScreen.kt sets WalletHomeAssetItem.amountValue from rawBalance?.toDoubleOrNull() ?: 0.0 (and jetton amount.toDoubleOrNull() ?: 0.0), and sets totalBalance from activeWallet?.balance?.toDoubleOrNull() ?: 0.0. Those Double values drive the count-up display (rememberCountUp(target: Double)), and final formatting uses formatCountUp(value: Double)BigDecimal.valueOf(value); so any precision loss (and any parse failure becoming 0.0) directly produces incorrect shown amounts. Keep balances as BigDecimal (or smallest-units integer) as the source of truth, and only derive an animated/display numeric value in a way that doesn’t drop precision (or gate/limit the conversion to safe ranges).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/WalletScreen.kt`
at line 158, The code currently converts balances to Double with lossy precision
and silently falls back to 0.0 (see WalletHomeAssetItem.amountValue assignment,
jetton amount parsing, and totalBalance from activeWallet?.balance), which
corrupts the animated/display values produced by rememberCountUp(target: Double)
and formatCountUp(value: Double). Change the source-of-truth to BigDecimal (or
smallest-unit long) in the model and UI: stop using toDoubleOrNull() fallbacks,
parse balances into BigDecimal (or integer nanos) and propagate that type;
either update rememberCountUp and formatCountUp to accept BigDecimal (or a
scaled long + scale factor) and perform animation calculations with
BigDecimal-safe conversions, or derive a safe, bounded Double only at the last
rendering step ensuring no precision loss for displayed ranges; also replace
silent 0.0 fallbacks with explicit error/empty-state handling so parse failures
don’t show incorrect zeros.

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)
Expand All @@ -177,6 +174,7 @@ private fun buildAssetList(
name = jetton.name,
symbol = jetton.symbol,
formattedAmount = "$amount ${jetton.symbol}",
amountValue = amount.toDoubleOrNull() ?: 0.0,
icon = icon,
)
}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
}
}

/**
Expand Down
9 changes: 6 additions & 3 deletions AndroidDemo/app/src/main/res/values/themes.xml
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
<resources>
<style name="Theme.WalletKitDemo" parent="Theme.Material3.DayNight.NoActionBar">
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/black</item>
<style name="Theme.WalletKitDemo" parent="Theme.Material3.Light.NoActionBar">
<item name="android:windowBackground">@android:color/white</item>
<item name="android:statusBarColor">@android:color/white</item>
<item name="android:navigationBarColor">@android:color/white</item>
<item name="android:windowLightStatusBar">true</item>
<item name="android:windowLightNavigationBar">true</item>
</style>
</resources>
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -41,6 +43,21 @@ private val LocalTonTypography = staticCompositionLocalOf<TonTypography> {
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(),
Expand All @@ -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)
}
}

Expand Down
Loading
Loading