diff --git a/app/src/main/java/to/bitkit/models/MoneyType.kt b/app/src/main/java/to/bitkit/models/MoneyType.kt new file mode 100644 index 0000000000..0d0a716086 --- /dev/null +++ b/app/src/main/java/to/bitkit/models/MoneyType.kt @@ -0,0 +1,6 @@ +package to.bitkit.models + +enum class MoneyType { + BITCOIN, + FIAT, +} diff --git a/app/src/main/java/to/bitkit/models/widget/CalculatorValues.kt b/app/src/main/java/to/bitkit/models/widget/CalculatorValues.kt index 7a40f051b0..ddc6583e3c 100644 --- a/app/src/main/java/to/bitkit/models/widget/CalculatorValues.kt +++ b/app/src/main/java/to/bitkit/models/widget/CalculatorValues.kt @@ -1,9 +1,32 @@ package to.bitkit.models.widget import kotlinx.serialization.Serializable +import to.bitkit.ext.removeSpaces +import to.bitkit.models.BitcoinDisplayUnit +import to.bitkit.ui.screens.widgets.calculator.calculatorBtcValueToSats @Serializable data class CalculatorValues( val btcValue: String = "10000", val fiatValue: String = "", + val satsValue: Long? = null, + val displayUnit: BitcoinDisplayUnit? = null, ) + +internal fun CalculatorValues.resolveCalculatorSatsValue(): Long { + satsValue?.let { return it } + if (btcValue.isEmpty()) return 0L + return calculatorBtcValueToSats( + btcValue = btcValue, + displayUnit = displayUnit ?: inferLegacyCalculatorDisplayUnit(btcValue), + ) +} + +private fun inferLegacyCalculatorDisplayUnit(btcValue: String): BitcoinDisplayUnit { + val normalizedValue = btcValue.removeSpaces() + return if (normalizedValue.any { it == '.' || it == ',' }) { + BitcoinDisplayUnit.CLASSIC + } else { + BitcoinDisplayUnit.MODERN + } +} diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index 54ebf1fc52..9ee45bac8f 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -497,6 +497,8 @@ fun ContentView( } ) { Box(modifier = Modifier.fillMaxSize()) { + var isHomeCalculatorInputActive by remember { mutableStateOf(false) } + RootNavHost( navController = navController, drawerState = drawerState, @@ -506,6 +508,7 @@ fun ContentView( settingsViewModel = settingsViewModel, currencyViewModel = currencyViewModel, transferViewModel = transferViewModel, + onHomeCalculatorInputActiveChanged = { isHomeCalculatorInputActive = it }, ) val navBackStackEntry by navController.currentBackStackEntryAsState() @@ -516,9 +519,18 @@ fun ContentView( Routes.Savings::class.qualifiedName, Routes.Spending::class.qualifiedName, ) + val hideTabBarForCalculator = + currentRoute == Routes.Home::class.qualifiedName && isHomeCalculatorInputActive + + LaunchedEffect(currentRoute) { + if (currentRoute != Routes.Home::class.qualifiedName) { + isHomeCalculatorInputActive = false + } + } if (showTabBar) { TabBar( + isVisible = !hideTabBarForCalculator, onSendClick = { appViewModel.showSheet(Sheet.Send()) }, onReceiveClick = { appViewModel.showSheet(Sheet.Receive()) }, onScanClick = { appViewModel.showSheet(Sheet.Send(SendRoute.QrScanner)) }, @@ -557,6 +569,7 @@ private fun RootNavHost( settingsViewModel: SettingsViewModel, currencyViewModel: CurrencyViewModel, transferViewModel: TransferViewModel, + onHomeCalculatorInputActiveChanged: (Boolean) -> Unit, ) { val scope = rememberCoroutineScope() @@ -568,6 +581,7 @@ private fun RootNavHost( settingsViewModel = settingsViewModel, navController = navController, drawerState = drawerState, + onCalculatorInputActiveChanged = onHomeCalculatorInputActiveChanged, ) allActivity( activityListViewModel = activityListViewModel, @@ -594,7 +608,7 @@ private fun RootNavHost( logs(navController) suggestions(navController) support(navController) - widgets(navController, settingsViewModel, currencyViewModel) + widgets(navController, settingsViewModel) update() recoveryMode(navController, appViewModel) @@ -811,6 +825,7 @@ private fun NavGraphBuilder.home( settingsViewModel: SettingsViewModel, navController: NavHostController, drawerState: DrawerState, + onCalculatorInputActiveChanged: (Boolean) -> Unit, ) { composable { val isRefreshing by walletViewModel.isRefreshing.collectAsStateWithLifecycle() @@ -837,6 +852,7 @@ private fun NavGraphBuilder.home( walletViewModel = walletViewModel, appViewModel = appViewModel, activityListViewModel = activityListViewModel, + onCalculatorInputActiveChanged = onCalculatorInputActiveChanged, ) } } @@ -1465,7 +1481,6 @@ private fun NavGraphBuilder.support( private fun NavGraphBuilder.widgets( navController: NavHostController, settingsViewModel: SettingsViewModel, - currencyViewModel: CurrencyViewModel, ) { composableWithDefaultTransitions { WidgetsIntroScreen( @@ -1508,7 +1523,6 @@ private fun NavGraphBuilder.widgets( CalculatorPreviewScreen( onClose = { navController.navigateToHome() }, onBack = { navController.popBackStack() }, - currencyViewModel = currencyViewModel ) } navigationWithDefaultTransitions( diff --git a/app/src/main/java/to/bitkit/ui/MainActivity.kt b/app/src/main/java/to/bitkit/ui/MainActivity.kt index 12870a400f..14dd51edf1 100644 --- a/app/src/main/java/to/bitkit/ui/MainActivity.kt +++ b/app/src/main/java/to/bitkit/ui/MainActivity.kt @@ -64,7 +64,7 @@ import to.bitkit.viewmodels.WalletViewModel @AndroidEntryPoint class MainActivity : FragmentActivity() { private companion object { - const val KEY_CONSUMED_DEEPLINK_URI = "consumed_deeplink_uri" + const val KEY_CONSUMED_LAUNCH_INTENT = "consumed_launch_intent" } private val appViewModel by viewModels() @@ -76,7 +76,7 @@ class MainActivity : FragmentActivity() { private val settingsViewModel by viewModels() private val backupsViewModel by viewModels() - @Suppress("LongMethod") + @Suppress("LongMethod", "CyclomaticComplexMethod") override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -88,10 +88,10 @@ class MainActivity : FragmentActivity() { importance = NotificationManager.IMPORTANCE_LOW ) - val consumedUri = savedInstanceState?.getString(KEY_CONSUMED_DEEPLINK_URI) - val currentUri = intent?.data?.toString() - if (currentUri == null || currentUri != consumedUri) { - appViewModel.handleDeeplinkIntent(intent) + val consumedLaunchIntent = savedInstanceState?.getString(KEY_CONSUMED_LAUNCH_INTENT) + val currentLaunchIntent = intent.launchKey() + if (currentLaunchIntent == null || currentLaunchIntent != consumedLaunchIntent) { + appViewModel.handleLaunchIntent(intent) } installSplashScreen() @@ -207,12 +207,12 @@ class MainActivity : FragmentActivity() { override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) setIntent(intent) - appViewModel.handleDeeplinkIntent(intent) + appViewModel.handleLaunchIntent(intent) } override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) - intent?.data?.toString()?.let { outState.putString(KEY_CONSUMED_DEEPLINK_URI, it) } + intent.launchKey()?.let { outState.putString(KEY_CONSUMED_LAUNCH_INTENT, it) } } override fun onDestroy() { @@ -237,6 +237,14 @@ class MainActivity : FragmentActivity() { } } +private fun Intent?.launchKey(): String? { + this ?: return null + return when (action) { + Intent.ACTION_VIEW -> data?.toString() + else -> null + } +} + @Composable private fun OnboardingNav( startupNavController: NavHostController, diff --git a/app/src/main/java/to/bitkit/ui/components/NumberPad.kt b/app/src/main/java/to/bitkit/ui/components/NumberPad.kt index 240ef62e20..e5a49c2b6f 100644 --- a/app/src/main/java/to/bitkit/ui/components/NumberPad.kt +++ b/app/src/main/java/to/bitkit/ui/components/NumberPad.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items @@ -71,13 +72,20 @@ fun NumberPad( modifier: Modifier = Modifier, type: NumberPadType = NumberPadType.SIMPLE, availableHeight: Dp = defaultHeight, + decimalSeparator: String = KEY_DECIMAL, errorKey: String? = null, + includeNavigationBarsPadding: Boolean = false, ) { val focusRequester = remember { FocusRequester() } LaunchedEffect(Unit) { focusRequester.requestFocus() } + val safeAreaModifier = if (includeNavigationBarsPadding) { + modifier.navigationBarsPadding() + } else { + modifier + } BoxWithConstraints( - modifier = modifier + modifier = safeAreaModifier .focusRequester(focusRequester) .onPreviewKeyEvent { keyEvent -> if (keyEvent.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false @@ -124,9 +132,10 @@ fun NumberPad( ) NumberPadType.DECIMAL -> NumberPadKeyButton( - text = KEY_DECIMAL, + text = decimalSeparator, onPress = onPress, height = buttonHeight, + key = KEY_DECIMAL, hasError = errorKey == KEY_DECIMAL, testTag = "NDecimal", ) @@ -161,6 +170,8 @@ fun NumberPad( currencies: CurrencyState = LocalCurrencies.current, type: NumberPadType = viewModel.getNumberPadType(currencies), availableHeight: Dp = defaultHeight, + decimalSeparator: String = KEY_DECIMAL, + includeNavigationBarsPadding: Boolean = false, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() NumberPad( @@ -168,7 +179,9 @@ fun NumberPad( modifier = modifier, type = type, availableHeight = availableHeight, + decimalSeparator = decimalSeparator, errorKey = uiState.errorKey, + includeNavigationBarsPadding = includeNavigationBarsPadding, ) } @@ -186,7 +199,7 @@ private val hardwareKeyMap = mapOf( Key.Eight to "8", Key.NumPad8 to "8", Key.Nine to "9", Key.NumPad9 to "9", Key.Backspace to KEY_DELETE, Key.Delete to KEY_DELETE, - Key.Period to KEY_DECIMAL, Key.NumPadDot to KEY_DECIMAL, + Key.Period to KEY_DECIMAL, Key.NumPadDot to KEY_DECIMAL, Key.Comma to KEY_DECIMAL, ) private fun mapHardwareKey(key: Key, type: NumberPadType): String? { @@ -201,11 +214,12 @@ fun NumberPadKeyButton( onPress: (String) -> Unit, height: Dp, modifier: Modifier = Modifier, + key: String = text, hasError: Boolean = false, testTag: String = "N$text", ) { NumberPadKey( - onClick = { onPress(text) }, + onClick = { onPress(key) }, height = height, haptic = if (hasError) errorHaptic else pressHaptic, modifier = modifier.testTag(testTag), diff --git a/app/src/main/java/to/bitkit/ui/components/Spacers.kt b/app/src/main/java/to/bitkit/ui/components/Spacers.kt index 8d54132a16..3c07e477d0 100644 --- a/app/src/main/java/to/bitkit/ui/components/Spacers.kt +++ b/app/src/main/java/to/bitkit/ui/components/Spacers.kt @@ -58,13 +58,20 @@ fun RowScope.FillWidth( @Composable fun StatusBarSpacer(modifier: Modifier = Modifier) { Spacer( - modifier = modifier.height(Insets.Top), + modifier = modifier.height(Insets.Top) ) } @Composable fun TopBarSpacer(modifier: Modifier = Modifier) { Spacer( - modifier = modifier.height(TopBarHeight), + modifier = modifier.height(TopBarHeight) + ) +} + +@Composable +fun NavBarSpacer(modifier: Modifier = Modifier) { + Spacer( + modifier = modifier.height(Insets.Bottom) ) } diff --git a/app/src/main/java/to/bitkit/ui/components/TabBar.kt b/app/src/main/java/to/bitkit/ui/components/TabBar.kt index 1f0c22dd51..e9954f42ee 100644 --- a/app/src/main/java/to/bitkit/ui/components/TabBar.kt +++ b/app/src/main/java/to/bitkit/ui/components/TabBar.kt @@ -1,6 +1,14 @@ package to.bitkit.ui.components +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column @@ -24,17 +32,14 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.draw.shadow -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.BlendMode import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi @@ -52,161 +57,149 @@ private val iconSize = 20.dp const val TAB_BAR_HEIGHT = 56 const val TAB_BAR_PADDING_BOTTOM = 8 private const val GRADIENT_HEIGHT = 134 +private const val TAB_BAR_EXIT_DURATION_MS = 180 +private const val TAB_BAR_SETTLE_DAMPING_RATIO = 0.8f private val buttonLeftShape = RoundedCornerShape(topStartPercent = 50, bottomStartPercent = 50) private val buttonRightShape = RoundedCornerShape(topEndPercent = 50, bottomEndPercent = 50) +private val tabBarEnterOffsetSpring = spring( + dampingRatio = TAB_BAR_SETTLE_DAMPING_RATIO, + stiffness = Spring.StiffnessMediumLow, +) +private val tabBarExitOffsetTween = tween( + durationMillis = TAB_BAR_EXIT_DURATION_MS, + easing = FastOutSlowInEasing, +) @OptIn(ExperimentalHazeMaterialsApi::class) @Composable fun BoxScope.TabBar( modifier: Modifier = Modifier, + isVisible: Boolean = true, onSendClick: () -> Unit = {}, onReceiveClick: () -> Unit = {}, onScanClick: () -> Unit = {}, ) { - Box( + AnimatedVisibility( + visible = isVisible, + enter = slideInVertically( + initialOffsetY = { it }, + animationSpec = tabBarEnterOffsetSpring, + ), + exit = slideOutVertically( + targetOffsetY = { it }, + animationSpec = tabBarExitOffsetTween, + ), modifier = modifier .align(Alignment.BottomCenter) - .fillMaxWidth() + .fillMaxWidth(), ) { Box( modifier = Modifier - .align(Alignment.BottomCenter) .fillMaxWidth() - .height(GRADIENT_HEIGHT.dp) - .background( - Brush.verticalGradient( - colors = listOf(Color.Transparent, Colors.Black), - ) - ) - ) - Box( - contentAlignment = Alignment.Center, - modifier = Modifier - .align(Alignment.BottomCenter) - .fillMaxWidth() - .padding(horizontal = 16.dp) - .padding(bottom = TAB_BAR_PADDING_BOTTOM.dp) - .navigationBarsPadding() ) { - Row( - modifier = Modifier.primaryButtonStyle( - isEnabled = true, - shape = MaterialTheme.shapes.large, - ) + Box( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .height(GRADIENT_HEIGHT.dp) + .background( + Brush.verticalGradient( + colors = listOf(Color.Transparent, Colors.Black), + ) + ) + ) + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = TAB_BAR_PADDING_BOTTOM.dp) + .navigationBarsPadding() ) { - // Send Button - Box( - contentAlignment = Alignment.Center, - modifier = Modifier - .weight(1f) - .height(TAB_BAR_HEIGHT.dp) - .clip(buttonLeftShape) - .clickableAlpha(ripple = true) { onSendClick() } - .testTag("Send") + Row( + modifier = Modifier.primaryButtonStyle( + isEnabled = true, + shape = MaterialTheme.shapes.large, + ) ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - imageVector = Icons.Default.ArrowUpward, - contentDescription = stringResource(R.string.wallet__send), - modifier = Modifier.size(iconSize) - ) - Spacer(Modifier.width(iconToTextGap)) - BodySSB(text = stringResource(R.string.wallet__send)) + // Send Button + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .weight(1f) + .height(TAB_BAR_HEIGHT.dp) + .clip(buttonLeftShape) + .clickableAlpha(ripple = true, enabled = isVisible) { onSendClick() } + .testTag("Send") + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Default.ArrowUpward, + contentDescription = stringResource(R.string.wallet__send), + modifier = Modifier.size(iconSize) + ) + Spacer(Modifier.width(iconToTextGap)) + BodySSB(text = stringResource(R.string.wallet__send)) + } + } + + // Receive Button + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .weight(1f) + .height(TAB_BAR_HEIGHT.dp) + .clip(buttonRightShape) + .clickableAlpha(ripple = true, enabled = isVisible) { onReceiveClick() } + .testTag("Receive") + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Default.ArrowDownward, + contentDescription = stringResource(R.string.wallet__receive), + modifier = Modifier.size(iconSize) + ) + Spacer(Modifier.width(iconToTextGap)) + BodySSB(text = stringResource(R.string.wallet__receive)) + } } } - // Receive Button + // Scan button Box( contentAlignment = Alignment.Center, modifier = Modifier - .weight(1f) - .height(TAB_BAR_HEIGHT.dp) - .clip(buttonRightShape) - .clickableAlpha(ripple = true) { onReceiveClick() } - .testTag("Receive") - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - imageVector = Icons.Default.ArrowDownward, - contentDescription = stringResource(R.string.wallet__receive), - modifier = Modifier.size(iconSize) + .size(64.dp) + .shadow( + elevation = 25.dp, + shape = CircleShape, + ambientColor = Colors.Black25, + spotColor = Colors.Black25 ) - Spacer(Modifier.width(iconToTextGap)) - BodySSB(text = stringResource(R.string.wallet__receive)) - } - } - } - - // Scan button - Box( - contentAlignment = Alignment.Center, - modifier = Modifier - .size(64.dp) - // Shadow 1: gray2 shadow with radius 0 at y=-1 (top highlight) - .drawWithContent { - // Draw a prominent top highlight - drawCircle( - color = Colors.Gray2, - radius = size.width / 2, - center = Offset(size.width / 2, size.height / 2 - 1.5.dp.toPx()), - alpha = 0.6f - ) - drawContent() - } - // Shadow 2: black 25% opacity, radius 25, y offset 20 - .shadow( - elevation = 25.dp, - shape = CircleShape, - ambientColor = Colors.Black25, - spotColor = Colors.Black25 - ) - .clip(CircleShape) - .background(Colors.Gray7) - // Overlay: Circle strokeBorder with linear gradient mask (iOS: .mask) - .drawWithContent { - drawContent() - - // The mask gradient goes from black (visible) at top to clear (invisible) at bottom - val borderWidth = 2.dp.toPx() - - // Create vertical gradient mask (black to clear) - val maskGradient = Brush.verticalGradient( - colors = listOf( - Color.White, // Top: full opacity (shows border) - Color.Transparent // Bottom: transparent (hides border) + .clip(CircleShape) + .background(Colors.Gray7) + .border( + width = 2.dp, + brush = Brush.verticalGradient( + colors = listOf( + Colors.Gray2.copy(alpha = 0.6f), + Color.Transparent, + ), ), - startY = 0f, - endY = size.height - ) - - // Draw solid black circular border first, then apply gradient as alpha mask - drawCircle( - color = Color.Black, - radius = (size.width - borderWidth) / 2, - center = Offset(size.width / 2, size.height / 2), - style = Stroke(width = borderWidth), - alpha = 1f - ) - - // Apply gradient mask by drawing gradient as overlay with BlendMode - drawCircle( - brush = maskGradient, - radius = (size.width - borderWidth) / 2, - center = Offset(size.width / 2, size.height / 2), - style = Stroke(width = borderWidth), - blendMode = BlendMode.DstIn + shape = CircleShape, ) - } - .clickableAlpha(ripple = true) { onScanClick() } - .testTag("Scan") - ) { - Icon( - painter = painterResource(R.drawable.ic_scan), - contentDescription = stringResource(R.string.wallet__recipient_scan), - tint = Colors.Gray1, - modifier = Modifier.size(22.dp) - ) + .clickableAlpha(ripple = true, enabled = isVisible) { onScanClick() } + .testTag("Scan") + ) { + Icon( + painter = painterResource(R.drawable.ic_scan), + contentDescription = stringResource(R.string.wallet__recipient_scan), + tint = Colors.Gray1, + modifier = Modifier.size(22.dp) + ) + } } } } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt index df31511194..a1eb1b1cef 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt @@ -1,13 +1,17 @@ package to.bitkit.ui.screens.wallets import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateContentSize import androidx.compose.animation.core.AnimationConstants import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.exponentialDecay import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween import androidx.compose.foundation.Image import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints @@ -19,7 +23,7 @@ import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.ime import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBars @@ -48,20 +52,35 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.changedToUpIgnoreConsumed +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.boundsInRoot +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInParent import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Devices.PIXEL_TABLET import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel @@ -118,7 +137,6 @@ import to.bitkit.ui.components.Title import to.bitkit.ui.components.TopBarSpacer import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.components.WalletBalanceView -import to.bitkit.ui.currencyViewModel import to.bitkit.ui.navigateTo import to.bitkit.ui.navigateToActivityItem import to.bitkit.ui.navigateToAllActivity @@ -145,6 +163,7 @@ import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors import to.bitkit.ui.theme.Insets import to.bitkit.ui.theme.TopBarGradient +import to.bitkit.ui.theme.TopBarHeight import to.bitkit.ui.utils.withAccent import to.bitkit.viewmodels.ActivityListViewModel import to.bitkit.viewmodels.AppViewModel @@ -167,6 +186,7 @@ fun HomeScreen( walletViewModel: WalletViewModel, appViewModel: AppViewModel, activityListViewModel: ActivityListViewModel, + onCalculatorInputActiveChanged: (Boolean) -> Unit = {}, homeViewModel: HomeViewModel = hiltViewModel(), ) { val context = LocalContext.current @@ -322,6 +342,7 @@ fun HomeScreen( onNavigateToActivityItem = { rootNavController.navigateToActivityItem(it) }, onNavigateToSavings = { walletNavController.navigate(Routes.Savings) }, onNavigateToSpending = { walletNavController.navigate(Routes.Spending) }, + onCalculatorInputActiveChanged = onCalculatorInputActiveChanged, ) } @@ -352,15 +373,30 @@ private fun Content( onNavigateToActivityItem: (String) -> Unit = {}, onNavigateToSavings: () -> Unit = {}, onNavigateToSpending: () -> Unit = {}, + onCalculatorInputActiveChanged: (Boolean) -> Unit = {}, hazeState: HazeState = rememberHazeState(), balances: BalanceState = LocalBalances.current, ) { val scope = rememberCoroutineScope() + val focusManager = LocalFocusManager.current + val keyboardController = LocalSoftwareKeyboardController.current + var calculatorInputDismissKey by remember { mutableIntStateOf(0) } + var isCalculatorInputActive by remember { mutableStateOf(false) } + val onCalculatorInputActiveChange = { isActive: Boolean -> + isCalculatorInputActive = isActive + onCalculatorInputActiveChanged(isActive) + } val pageCount = if (homeUiState.showWidgets) 2 else 1 val pagerState = rememberPagerState( initialPage = homeUiState.currentPage, pageCount = { pageCount }, ) + fun dismissKeyboard(callback: (() -> Unit)? = null) { + focusManager.clearFocus(force = true) + keyboardController?.hide() + calculatorInputDismissKey++ + callback?.invoke() + } LaunchedEffect(pagerState.currentPage) { onPageChanged(pagerState.currentPage) @@ -369,6 +405,12 @@ private fun Content( } } + LaunchedEffect(pagerState.currentPage, isCalculatorInputActive) { + if (pagerState.currentPage == 0 && isCalculatorInputActive) { + dismissKeyboard() + } + } + val density = LocalDensity.current val screenHeightDp = with(density) { LocalWindowInfo.current.containerSize.height.toDp().value.toInt() } val isSmallScreen = screenHeightDp < SMALL_SCREEN_HEIGHT_DP @@ -385,12 +427,28 @@ private fun Content( hazeState = hazeState, profileDisplayName = profileDisplayName, profileDisplayImageUri = profileDisplayImageUri, - onClickProfile = onClickProfile, + onClickProfile = { + dismissKeyboard { + onClickProfile() + } + }, showEditWidgets = homeUiState.currentPage == 1 && homeUiState.showWidgets, isEditingWidgets = homeUiState.isEditingWidgets, - onClickEditWidgetList = onClickEditWidgetList, - onNavigateToAppStatus = onNavigateToAppStatus, - onOpenDrawer = { scope.launch { drawerState.open() } }, + onClickEditWidgetList = { + dismissKeyboard { + onClickEditWidgetList() + } + }, + onNavigateToAppStatus = { + dismissKeyboard { + onNavigateToAppStatus() + } + }, + onOpenDrawer = { + dismissKeyboard { + scope.launch { drawerState.open() } + } + }, ) VerticalPager( @@ -407,7 +465,6 @@ private fun Content( ), modifier = Modifier .fillMaxSize() - .imePadding() .hazeSource(state = hazeState) .zIndex(0f) ) { page -> @@ -428,6 +485,10 @@ private fun Content( 1 -> WidgetsPage( homeUiState = homeUiState, + calculatorInputDismissKey = calculatorInputDismissKey, + isCalculatorInputActive = isCalculatorInputActive, + onDismissCalculatorInput = ::dismissKeyboard, + onCalculatorInputActiveChanged = onCalculatorInputActiveChange, onRemoveSuggestion = onRemoveSuggestion, onClickSuggestion = onClickSuggestion, onClickAddWidget = onClickAddWidget, @@ -596,6 +657,10 @@ private fun BalancesSection( @Composable private fun WidgetsPage( homeUiState: HomeUiState, + calculatorInputDismissKey: Int, + isCalculatorInputActive: Boolean, + onDismissCalculatorInput: () -> Unit, + onCalculatorInputActiveChanged: (Boolean) -> Unit, onRemoveSuggestion: (Suggestion) -> Unit, onClickSuggestion: (Suggestion) -> Unit, onClickAddWidget: () -> Unit, @@ -603,12 +668,46 @@ private fun WidgetsPage( onClickDeleteWidget: (WidgetType) -> Unit, onMoveWidget: (Int, Int) -> Unit, ) { - Box(modifier = Modifier.fillMaxSize()) { + val imeBottomPadding = WindowInsets.ime.asPaddingValues().calculateBottomPadding() + val widgetsScrollState = rememberScrollState() + val calculatorIndex = homeUiState.widgetsWithPosition.indexOfFirst { it.type == WidgetType.CALCULATOR } + val shouldAnchorCalculatorNumpad = isCalculatorInputActive && calculatorIndex != -1 + var pageBounds by remember { mutableStateOf(null) } + var calculatorBounds by remember { mutableStateOf(null) } + val scrollModifier = if (shouldAnchorCalculatorNumpad) { + Modifier + } else { + Modifier.verticalScroll( + state = widgetsScrollState, + enabled = !isCalculatorInputActive, + ) + } + + LaunchedEffect(homeUiState.widgetsWithPosition, homeUiState.isEditingWidgets) { + val hasCalculator = homeUiState.widgetsWithPosition.any { it.type == WidgetType.CALCULATOR } + if (homeUiState.isEditingWidgets || !hasCalculator) { + calculatorBounds = null + } + } + + BoxWithConstraints( + modifier = Modifier + .fillMaxSize() + .onGloballyPositioned { pageBounds = it.boundsInRoot() } + .dismissCalculatorInputOnOutsideTap( + isCalculatorInputActive = isCalculatorInputActive, + pageBounds = pageBounds, + calculatorBounds = calculatorBounds, + onDismiss = onDismissCalculatorInput, + ) + ) { + val anchoredWidgetsHeight = maxHeight - Insets.Top - TopBarHeight - 16.dp + Column( modifier = Modifier .padding(horizontal = 16.dp) .fillMaxSize() - .verticalScroll(rememberScrollState()) + .then(scrollModifier) ) { StatusBarSpacer() TopBarSpacer() @@ -633,24 +732,74 @@ private fun WidgetsPage( } else { Widgets( homeUiState = homeUiState, + calculatorInputDismissKey = calculatorInputDismissKey, + shouldAnchorCalculatorNumpad = shouldAnchorCalculatorNumpad, + calculatorAnchorHeight = anchoredWidgetsHeight, + onCalculatorInputActiveChanged = onCalculatorInputActiveChanged, + onCalculatorBoundsChanged = { calculatorBounds = it }, onRemoveSuggestion = onRemoveSuggestion, onClickSuggestion = onClickSuggestion, + modifier = if (shouldAnchorCalculatorNumpad) { + Modifier.weight(1f) + } else { + Modifier.fillMaxWidth() + } ) } - VerticalSpacer(16.dp) + if (!shouldAnchorCalculatorNumpad) { + val footerAlpha = if (isCalculatorInputActive) 0f else 1f - TertiaryButton( - text = stringResource(R.string.widgets__add), - onClick = onClickAddWidget, - modifier = Modifier.testTag("WidgetsAdd") - ) + VerticalSpacer(16.dp) - VerticalSpacer(150.dp) + TertiaryButton( + text = stringResource(R.string.widgets__add), + onClick = onClickAddWidget, + enabled = !isCalculatorInputActive, + modifier = Modifier + .alpha(footerAlpha) + .testTag("WidgetsAdd") + ) + + VerticalSpacer(150.dp + imeBottomPadding) + } } } } +private fun Modifier.dismissCalculatorInputOnOutsideTap( + isCalculatorInputActive: Boolean, + pageBounds: Rect?, + calculatorBounds: Rect?, + onDismiss: () -> Unit, +): Modifier = pointerInput(isCalculatorInputActive, pageBounds, calculatorBounds) { + if (!isCalculatorInputActive) return@pointerInput + + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial) + val page = pageBounds ?: return@awaitEachGesture + val calculator = calculatorBounds ?: return@awaitEachGesture + val tapPositionInRoot = down.position.toRootPosition(page) + var isTap = true + var isPointerUp = false + + while (!isPointerUp) { + val event = awaitPointerEvent(pass = PointerEventPass.Initial) + val pointer = event.changes.firstOrNull { it.id == down.id } ?: return@awaitEachGesture + if ((pointer.position - down.position).getDistance() > viewConfiguration.touchSlop) { + isTap = false + } + isPointerUp = pointer.changedToUpIgnoreConsumed() + } + + if (isTap && !calculator.contains(tapPositionInRoot)) { + onDismiss() + } + } +} + +private fun Offset.toRootPosition(bounds: Rect): Offset = this + Offset(bounds.left, bounds.top) + @Composable private fun SuggestionsSection( suggestions: ImmutableList, @@ -720,14 +869,50 @@ private fun WidgetsOnboardingHint(modifier: Modifier = Modifier) { @Composable private fun Widgets( homeUiState: HomeUiState, + calculatorInputDismissKey: Int, + shouldAnchorCalculatorNumpad: Boolean, + calculatorAnchorHeight: Dp, + onCalculatorInputActiveChanged: (Boolean) -> Unit, + onCalculatorBoundsChanged: (Rect) -> Unit, onRemoveSuggestion: (Suggestion) -> Unit, onClickSuggestion: (Suggestion) -> Unit, + modifier: Modifier = Modifier, ) { + val density = LocalDensity.current + var calculatorNaturalBottom by remember { mutableStateOf(0.dp) } + val widgets = if (shouldAnchorCalculatorNumpad) { + val calculatorIndex = homeUiState.widgetsWithPosition.indexOfFirst { it.type == WidgetType.CALCULATOR } + if (calculatorIndex == -1) { + homeUiState.widgetsWithPosition + } else { + homeUiState.widgetsWithPosition.take(calculatorIndex + 1) + } + } else { + homeUiState.widgetsWithPosition + } + val targetCalculatorTopPadding = if (shouldAnchorCalculatorNumpad && calculatorNaturalBottom > 0.dp) { + (calculatorAnchorHeight - calculatorNaturalBottom).coerceAtLeast(0.dp) + } else { + 0.dp + } + val calculatorTopPadding by animateDpAsState( + targetValue = targetCalculatorTopPadding, + label = "calculatorTopPadding", + ) + Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(16.dp) + modifier = modifier + .fillMaxWidth() + .animateContentSize( + animationSpec = tween(durationMillis = AnimationConstants.DefaultDurationMillis), + ), + verticalArrangement = if (shouldAnchorCalculatorNumpad) { + Arrangement.Top + } else { + Arrangement.spacedBy(16.dp) + } ) { - homeUiState.widgetsWithPosition.forEach { widgetsWithPosition -> + widgets.forEach { widgetsWithPosition -> when (widgetsWithPosition.type) { WidgetType.BLOCK -> { homeUiState.currentBlock?.run { @@ -754,13 +939,26 @@ private fun Widgets( } WidgetType.CALCULATOR -> { - currencyViewModel?.let { - CalculatorCard( - currencyViewModel = it, - showWidgetTitle = homeUiState.showWidgetTitles, - modifier = Modifier.fillMaxWidth() - ) + if (shouldAnchorCalculatorNumpad) { + VerticalSpacer(calculatorTopPadding) } + + CalculatorCard( + dismissNumberPadKey = calculatorInputDismissKey, + onInputActiveChange = onCalculatorInputActiveChanged, + modifier = Modifier + .fillMaxWidth() + .onGloballyPositioned { + if (shouldAnchorCalculatorNumpad) { + calculatorNaturalBottom = with(density) { + it.positionInParent().y.toDp() + + it.size.height.toDp() - + calculatorTopPadding + } + } + onCalculatorBoundsChanged(it.boundsInRoot()) + } + ) } WidgetType.FACTS -> { diff --git a/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/CalculatorDisplay.kt b/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/CalculatorDisplay.kt new file mode 100644 index 0000000000..004025ba4c --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/CalculatorDisplay.kt @@ -0,0 +1,307 @@ +package to.bitkit.ui.screens.widgets.calculator + +import to.bitkit.ext.removeSpaces +import to.bitkit.models.BitcoinDisplayUnit +import to.bitkit.models.CLASSIC_DECIMALS +import to.bitkit.models.DECIMAL_SEPARATOR +import to.bitkit.models.FIAT_GROUPING_SEPARATOR +import to.bitkit.models.SATS_GROUPING_SEPARATOR +import to.bitkit.models.SATS_IN_BTC +import to.bitkit.models.asBtc +import to.bitkit.models.formatCurrency +import to.bitkit.ui.components.KEY_000 +import to.bitkit.ui.components.KEY_DECIMAL +import to.bitkit.ui.components.KEY_DELETE +import java.math.BigDecimal +import java.math.RoundingMode +import java.text.DecimalFormatSymbols +import java.util.Locale + +internal fun calculatorBtcValueToSats( + btcValue: String, + displayUnit: BitcoinDisplayUnit, +): Long { + val satsOrBtc = btcValue.removeSpaces() + return when (displayUnit) { + BitcoinDisplayUnit.MODERN -> satsOrBtc.toSatsLongClamped() + BitcoinDisplayUnit.CLASSIC -> { + val roundedNumber = satsOrBtc.toClassicSatsDecimalOrNull() ?: BigDecimal.ZERO + roundedNumber.toSatsLongClamped() + } + } +} + +internal fun isBtcValueInSatsRange( + btcValue: String, + displayUnit: BitcoinDisplayUnit, +): Boolean { + val satsOrBtc = btcValue.removeSpaces() + if (satsOrBtc.isEmpty()) return true + return when (displayUnit) { + BitcoinDisplayUnit.MODERN -> satsOrBtc.toLongOrNull() != null + BitcoinDisplayUnit.CLASSIC -> { + val roundedNumber = satsOrBtc.toClassicSatsDecimalOrNull() ?: return false + roundedNumber >= BigDecimal.ZERO && roundedNumber <= MAX_SATS_DECIMAL + } + } +} + +internal fun calculatorSatsToBtcValue( + sats: Long, + displayUnit: BitcoinDisplayUnit, +): String { + return when (displayUnit) { + BitcoinDisplayUnit.MODERN -> sats.toString() + BitcoinDisplayUnit.CLASSIC -> sats.asBtc() + .formatCurrency(decimalPlaces = CLASSIC_DECIMALS) + .orEmpty() + } +} + +internal fun formatBitcoinValue( + btcValue: String, + displayUnit: BitcoinDisplayUnit, + locale: Locale = Locale.getDefault(), +): String { + if (btcValue.isEmpty()) return "" + return when (displayUnit) { + BitcoinDisplayUnit.MODERN -> formatGroupedInteger( + value = btcValue.filter { it.isDigit() }, + groupingSeparator = SATS_GROUPING_SEPARATOR, + ) + + BitcoinDisplayUnit.CLASSIC -> formatGroupedDecimal( + value = sanitizeDecimalInput(btcValue, locale), + groupingSeparator = SATS_GROUPING_SEPARATOR, + decimalSeparator = DECIMAL_SEPARATOR, + ) + } +} + +internal fun formatFiatValue( + fiatValue: String, + locale: Locale = Locale.getDefault(), +): String { + if (fiatValue.isEmpty()) return "" + val normalizedFiatValue = sanitizeDecimalInput( + raw = normalizeCalculatorDecimalInput( + rawValue = fiatValue, + locale = locale, + maxDecimalPlaces = CALCULATOR_FIAT_DECIMAL_PLACES, + ), + maxDecimalPlaces = CALCULATOR_FIAT_DECIMAL_PLACES, + ) + return formatGroupedDecimal( + value = normalizedFiatValue, + groupingSeparator = FIAT_GROUPING_SEPARATOR, + decimalSeparator = DECIMAL_SEPARATOR, + ) +} + +internal fun formatBitcoinPlaceholder( + btcValue: String, + displayUnit: BitcoinDisplayUnit, + locale: Locale = Locale.getDefault(), +): String { + if (btcValue.isEmpty() || displayUnit.isModern()) return "" + val normalizedBtcValue = sanitizeDecimalInput( + raw = normalizeCalculatorDecimalInput( + rawValue = btcValue, + locale = locale, + maxDecimalPlaces = CLASSIC_DECIMALS, + ), + maxDecimalPlaces = CLASSIC_DECIMALS, + ) + return formatMissingDecimalZeros( + value = normalizedBtcValue, + maxDecimalPlaces = CLASSIC_DECIMALS, + ) +} + +internal fun formatFiatPlaceholder( + fiatValue: String, + locale: Locale = Locale.getDefault(), +): String { + if (fiatValue.isEmpty()) return "" + val normalizedFiatValue = sanitizeDecimalInput( + raw = normalizeCalculatorDecimalInput( + rawValue = fiatValue, + locale = locale, + maxDecimalPlaces = CALCULATOR_FIAT_DECIMAL_PLACES, + ), + maxDecimalPlaces = CALCULATOR_FIAT_DECIMAL_PLACES, + ) + return formatMissingDecimalZeros( + value = normalizedFiatValue, + maxDecimalPlaces = CALCULATOR_FIAT_DECIMAL_PLACES, + ) +} + +internal fun applyNumberPadInput( + rawValue: String, + key: String, + maxDecimalPlaces: Int?, + locale: Locale = Locale.getDefault(), +): String { + val normalizedRawValue = if (maxDecimalPlaces == null) { + rawValue + } else { + normalizeCalculatorDecimalInput( + rawValue = rawValue, + locale = locale, + maxDecimalPlaces = maxDecimalPlaces, + ) + } + val nextValue = when { + key == KEY_DELETE -> normalizedRawValue.dropLast(1) + key == KEY_DECIMAL -> appendDecimalSeparator(normalizedRawValue, maxDecimalPlaces) + key == KEY_000 -> appendCalculatorDigits(normalizedRawValue, KEY_000) + key.length == 1 && key.first().isDigit() -> appendCalculatorDigits(normalizedRawValue, key) + else -> normalizedRawValue + } + + return if (maxDecimalPlaces == null) { + sanitizeIntegerInput(nextValue) + } else { + sanitizeDecimalInput( + raw = nextValue, + locale = locale, + maxDecimalPlaces = maxDecimalPlaces, + ) + } +} + +private fun normalizeCalculatorDecimalInput( + rawValue: String, + locale: Locale, + maxDecimalPlaces: Int?, +): String { + val value = rawValue.removeSpaces() + val hasComma = value.contains(COMMA_SEPARATOR) + val hasPeriod = value.contains(PERIOD_SEPARATOR) + if (hasComma && hasPeriod) return normalizeMixedDecimalSeparators(value) + if (!hasComma) return value + return if (shouldTreatCommaAsGrouping(value, locale, maxDecimalPlaces)) { + value.replace(oldValue = COMMA_SEPARATOR.toString(), newValue = "") + } else { + value.replace(oldChar = COMMA_SEPARATOR, newChar = PERIOD_SEPARATOR) + } +} + +private fun normalizeMixedDecimalSeparators(value: String): String { + val decimalSeparator = if (value.lastIndexOf(COMMA_SEPARATOR) > value.lastIndexOf(PERIOD_SEPARATOR)) { + COMMA_SEPARATOR + } else { + PERIOD_SEPARATOR + } + val groupingSeparator = if (decimalSeparator == COMMA_SEPARATOR) PERIOD_SEPARATOR else COMMA_SEPARATOR + return value + .replace(oldValue = groupingSeparator.toString(), newValue = "") + .replace(oldChar = decimalSeparator, newChar = PERIOD_SEPARATOR) +} + +private fun shouldTreatCommaAsGrouping( + value: String, + locale: Locale, + maxDecimalPlaces: Int?, +): Boolean { + if (value.count { it == COMMA_SEPARATOR } > 1) return true + + val decimalSeparator = DecimalFormatSymbols.getInstance(locale).decimalSeparator + if (decimalSeparator != COMMA_SEPARATOR) return true + + val fractionLength = value.substringAfter(COMMA_SEPARATOR).length + return maxDecimalPlaces != null && fractionLength > maxDecimalPlaces +} + +private fun formatGroupedInteger( + value: String, + groupingSeparator: Char, +): String { + if (value.isEmpty()) return "" + val normalized = value.trimStart('0').ifEmpty { "0" } + return normalized.reversed().chunked(GROUP_SIZE).joinToString(groupingSeparator.toString()).reversed() +} + +private fun formatGroupedDecimal( + value: String, + groupingSeparator: Char, + decimalSeparator: Char, +): String { + if (value.isEmpty()) return "" + if (value == ".") return decimalSeparator.toString() + + val decimalIndex = value.indexOf('.') + if (decimalIndex == -1) { + return formatGroupedIntegerPreservingZeros( + value = value, + groupingSeparator = groupingSeparator, + ) + } + + val integerPart = value.substring(0, decimalIndex) + val decimalPart = value.substring(decimalIndex + 1) + return formatGroupedIntegerPreservingZeros( + value = integerPart, + groupingSeparator = groupingSeparator, + ) + decimalSeparator + decimalPart +} + +private fun appendDecimalSeparator( + rawValue: String, + maxDecimalPlaces: Int?, +): String { + if (maxDecimalPlaces == null || rawValue.contains('.')) return rawValue + return if (rawValue.isEmpty()) "0." else "$rawValue." +} + +private fun appendCalculatorDigits(rawValue: String, digits: String): String { + if (rawValue != "0") return rawValue + digits + return digits.trimStart('0').ifEmpty { "0" } +} + +internal fun calculatorDecimalSeparator(): String = DECIMAL_SEPARATOR.toString() + +private fun formatGroupedIntegerPreservingZeros( + value: String, + groupingSeparator: Char, +): String { + if (value.isEmpty()) return "" + return value.reversed().chunked(GROUP_SIZE).joinToString(groupingSeparator.toString()).reversed() +} + +private fun formatMissingDecimalZeros( + value: String, + maxDecimalPlaces: Int, +): String { + if (!value.contains(PERIOD_SEPARATOR)) return "" + + val decimalLength = value.substringAfter(PERIOD_SEPARATOR).length + val remainingDecimals = maxDecimalPlaces - decimalLength + return if (remainingDecimals > 0) "0".repeat(remainingDecimals) else "" +} + +private fun String.toClassicSatsDecimalOrNull(): BigDecimal? { + val btcDecimal = toBigDecimalOrNull() ?: return null + return btcDecimal + .multiply(BigDecimal(SATS_IN_BTC)) + .setScale(0, RoundingMode.HALF_UP) +} + +private fun String.toSatsLongClamped(): Long { + if (isEmpty()) return 0L + if (any { !it.isDigit() }) return 0L + return toLongOrNull() ?: Long.MAX_VALUE +} + +private fun BigDecimal.toSatsLongClamped(): Long { + if (this <= BigDecimal.ZERO) return 0L + if (this > MAX_SATS_DECIMAL) return Long.MAX_VALUE + return toLong() +} + +private val MAX_SATS_DECIMAL = BigDecimal.valueOf(Long.MAX_VALUE) + +private const val GROUP_SIZE = 3 +private const val COMMA_SEPARATOR = ',' +private const val PERIOD_SEPARATOR = '.' diff --git a/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/CalculatorPreviewScreen.kt b/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/CalculatorPreviewScreen.kt index 8352afda1f..2729123153 100644 --- a/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/CalculatorPreviewScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/CalculatorPreviewScreen.kt @@ -1,62 +1,50 @@ package to.bitkit.ui.screens.widgets.calculator -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.expandVertically -import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.testTag -import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import to.bitkit.R -import to.bitkit.ext.spaceToNewline +import to.bitkit.models.BitcoinDisplayUnit import to.bitkit.ui.components.BodyM -import to.bitkit.ui.components.FillHeight -import to.bitkit.ui.components.Headline import to.bitkit.ui.components.PrimaryButton import to.bitkit.ui.components.SecondaryButton -import to.bitkit.ui.components.Text13Up import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.scaffold.AppTopBar -import to.bitkit.ui.scaffold.DrawerNavIcon -import to.bitkit.ui.screens.widgets.calculator.components.CalculatorCard -import to.bitkit.ui.shared.util.screen +import to.bitkit.ui.scaffold.ScreenColumn +import to.bitkit.ui.screens.widgets.calculator.components.CalculatorCardEditor +import to.bitkit.ui.screens.widgets.calculator.components.CalculatorCardSmall +import to.bitkit.ui.screens.widgets.components.WidgetSizeCarousel import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors -import to.bitkit.ui.utils.keyboardAsState -import to.bitkit.viewmodels.CurrencyViewModel @Composable fun CalculatorPreviewScreen( - viewModel: CalculatorViewModel = hiltViewModel(), - currencyViewModel: CurrencyViewModel?, onClose: () -> Unit, onBack: () -> Unit, + modifier: Modifier = Modifier, + viewModel: CalculatorViewModel = hiltViewModel(), ) { - val showWidgetTitles by viewModel.showWidgetTitles.collectAsStateWithLifecycle() val isCalculatorWidgetEnabled by viewModel.isCalculatorWidgetEnabled.collectAsStateWithLifecycle() + val uiState by viewModel.uiState.collectAsStateWithLifecycle() CalculatorPreviewContent( onBack = onBack, isCalculatorWidgetEnabled = isCalculatorWidgetEnabled, - showWidgetTitles = showWidgetTitles, + uiState = uiState, + onBtcChange = viewModel::onBtcInputChanged, + onFiatChange = viewModel::onFiatInputChanged, onClickDelete = { viewModel.removeWidget() onClose() @@ -65,7 +53,7 @@ fun CalculatorPreviewScreen( viewModel.saveWidget() onClose() }, - currencyViewModel = currencyViewModel + modifier = modifier ) } @@ -74,136 +62,123 @@ fun CalculatorPreviewContent( onBack: () -> Unit, onClickDelete: () -> Unit, onClickSave: () -> Unit, - showWidgetTitles: Boolean, - currencyViewModel: CurrencyViewModel?, isCalculatorWidgetEnabled: Boolean, + modifier: Modifier = Modifier, + uiState: CalculatorUiState = CalculatorUiState(), + onBtcChange: (String) -> Unit = {}, + onFiatChange: (String) -> Unit = {}, ) { - val isKeyboardVisible by keyboardAsState() - - Column( - modifier = Modifier - .screen() - .testTag("facts_preview_screen") + ScreenColumn( + modifier = modifier.testTag("calculator_preview_screen") ) { AppTopBar( - titleText = stringResource(R.string.widgets__widget__nav_title), + titleText = stringResource(R.string.widgets__calculator__name), onBackClick = onBack, - actions = { DrawerNavIcon() }, ) Column( modifier = Modifier - .imePadding() .padding(horizontal = 16.dp) - .testTag("main_content") + .weight(1f) ) { - AnimatedVisibility( - visible = !isKeyboardVisible, - enter = expandVertically(), - exit = shrinkVertically(), - ) { - Column { - VerticalSpacer(26.dp) - - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - modifier = Modifier - .fillMaxWidth() - .testTag("header_row") - ) { - Headline( - text = AnnotatedString( - stringResource(R.string.widgets__calculator__name).spaceToNewline(), - ), - modifier = Modifier.testTag("widget_title"), - ) - Icon( - painter = painterResource(R.drawable.widget_math_operation), - contentDescription = null, - tint = Color.Unspecified, - modifier = Modifier - .size(64.dp) - .testTag("widget_icon") - ) - } - - BodyM( - text = stringResource(R.string.widgets__facts__description), - color = Colors.White64, - modifier = Modifier - .padding(vertical = 16.dp) - .testTag("widget_description") - ) + VerticalSpacer(16.dp) - HorizontalDivider( - modifier = Modifier.testTag("divider") - ) - } - } - - if (!isKeyboardVisible) { - FillHeight() - } - - Text13Up( - stringResource(R.string.common__preview), + BodyM( + text = stringResource(R.string.widgets__calculator__description).replace( + "{fiatSymbol}", + uiState.currencySymbol, + ), color = Colors.White64, - modifier = Modifier - .padding(vertical = 16.dp) - .testTag("preview_label") + modifier = Modifier.testTag("widget_description") ) - currencyViewModel?.let { - CalculatorCard( - showWidgetTitle = showWidgetTitles, - currencyViewModel = it, - modifier = Modifier.fillMaxWidth() - ) - } + VerticalSpacer(16.dp) - Row( - horizontalArrangement = Arrangement.spacedBy(16.dp), - modifier = Modifier - .padding(vertical = 21.dp) - .fillMaxWidth() - .testTag("buttons_row") - ) { - if (isCalculatorWidgetEnabled) { - SecondaryButton( - text = stringResource(R.string.common__delete), - fullWidth = false, - onClick = onClickDelete, + HorizontalDivider( + modifier = Modifier.testTag("divider") + ) + + WidgetSizeCarousel( + smallContent = { + CalculatorCardSmall( + btcPrimaryDisplayUnit = uiState.displayUnit, + btcValue = uiState.btcValue, + fiatSymbol = uiState.currencySymbol, + fiatValue = uiState.fiatValue, + modifier = Modifier.testTag("calculator_card_small") + ) + }, + wideContent = { + CalculatorCardEditor( + btcPrimaryDisplayUnit = uiState.displayUnit, + btcValue = uiState.btcValue, + onBtcChange = onBtcChange, + fiatSymbol = uiState.currencySymbol, + fiatName = uiState.selectedCurrency, + fiatValue = uiState.fiatValue, + onFiatChange = onFiatChange, modifier = Modifier - .weight(1f) - .testTag("WidgetDelete") + .fillMaxWidth() + .testTag("calculator_card_wide") ) - } + }, + modifier = Modifier + .fillMaxWidth() + .testTag("calculator_preview_carousel") + ) + } - PrimaryButton( - text = stringResource(R.string.common__save), + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier + .padding( + start = 16.dp, + end = 16.dp, + bottom = 16.dp, + top = 22.dp, + ) + .fillMaxWidth() + .testTag("buttons_row") + ) { + if (isCalculatorWidgetEnabled) { + SecondaryButton( + text = stringResource(R.string.common__delete), fullWidth = false, - onClick = onClickSave, + onClick = onClickDelete, modifier = Modifier .weight(1f) - .testTag("WidgetSave") + .testTag("WidgetDelete") ) } + + PrimaryButton( + text = stringResource(R.string.widgets__widget__save), + fullWidth = false, + onClick = onClickSave, + modifier = Modifier + .weight(1f) + .testTag("WidgetSave") + ) } } } -@Preview(showBackground = true) +@Preview(showSystemUi = true) @Composable private fun Preview() { AppThemeSurface { CalculatorPreviewContent( onBack = {}, - showWidgetTitles = true, onClickDelete = {}, onClickSave = {}, isCalculatorWidgetEnabled = false, - currencyViewModel = null + uiState = CalculatorUiState( + btcValue = "10000", + fiatValue = "6.25", + displayUnit = BitcoinDisplayUnit.MODERN, + currencySymbol = "$", + selectedCurrency = "USD", + ), ) } } diff --git a/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/CalculatorViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/CalculatorViewModel.kt index 129a93f746..051f64b9c3 100644 --- a/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/CalculatorViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/CalculatorViewModel.kt @@ -1,23 +1,49 @@ package to.bitkit.ui.screens.widgets.calculator +import androidx.compose.runtime.Immutable import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import to.bitkit.models.BitcoinDisplayUnit import to.bitkit.models.WidgetType import to.bitkit.models.widget.CalculatorValues +import to.bitkit.models.widget.resolveCalculatorSatsValue +import to.bitkit.repositories.CurrencyRepo +import to.bitkit.repositories.CurrencyState import to.bitkit.repositories.WidgetsRepo +import java.math.BigDecimal +import java.math.RoundingMode +import java.text.DecimalFormatSymbols +import java.util.Locale import javax.inject.Inject +internal const val CALCULATOR_FIAT_DECIMAL_PLACES = 2 + @HiltViewModel class CalculatorViewModel @Inject constructor( - private val widgetsRepo: WidgetsRepo + private val widgetsRepo: WidgetsRepo, + private val currencyRepo: CurrencyRepo, ) : ViewModel() { + companion object { + private const val SUBSCRIPTION_TIMEOUT = 5000L + } + + private val _uiState = MutableStateFlow(CalculatorUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + private var pendingValues: CalculatorValues? = null + private var lastCurrencyKey: CalculatorCurrencyKey? = null + val isCalculatorWidgetEnabled: StateFlow = widgetsRepo.widgetsDataFlow .map { widgetsData -> widgetsData.widgets.any { it.type == WidgetType.CALCULATOR } @@ -27,22 +53,11 @@ class CalculatorViewModel @Inject constructor( started = SharingStarted.WhileSubscribed(SUBSCRIPTION_TIMEOUT), initialValue = false ) - val calculatorValues: StateFlow = widgetsRepo.widgetsDataFlow - .map { widgetsData -> - widgetsData.calculatorValues - } - .stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(SUBSCRIPTION_TIMEOUT), - initialValue = CalculatorValues() - ) - val showWidgetTitles: StateFlow = widgetsRepo.showWidgetTitles - .stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(SUBSCRIPTION_TIMEOUT), - initialValue = true - ) + init { + observeCalculatorState() + } + fun removeWidget() { viewModelScope.launch { widgetsRepo.deleteWidget(WidgetType.CALCULATOR) @@ -55,18 +70,323 @@ class CalculatorViewModel @Inject constructor( } } - fun updateCalculatorValues(fiatValue: String, btcValue: String) { + fun onBtcInputChanged(rawValue: String) { + val displayUnit = _uiState.value.displayUnit + val btcValue = if (displayUnit.isModern()) { + sanitizeIntegerInput(rawValue) + } else { + sanitizeDecimalInput(rawValue) + } + val satsValue = if (btcValue.isEmpty()) { + 0L + } else { + calculatorBtcValueToSats(btcValue, displayUnit) + } + val fiatValue = if (btcValue.isEmpty()) { + "" + } else { + convertSatsToFiat(satsValue) + } + updateCalculatorValues( + CalculatorValues( + btcValue = btcValue, + fiatValue = fiatValue, + satsValue = satsValue, + displayUnit = displayUnit, + ) + ) + } + + fun onFiatInputChanged(rawValue: String) { + val displayUnit = _uiState.value.displayUnit + val fiatValue = sanitizeDecimalInput(rawValue, maxDecimalPlaces = CALCULATOR_FIAT_DECIMAL_PLACES) + val satsValue = if (fiatValue.isEmpty()) 0L else convertFiatToSats(fiatValue) + val btcValue = if (fiatValue.isEmpty()) "" else calculatorSatsToBtcValue(satsValue, displayUnit) + updateCalculatorValues( + CalculatorValues( + btcValue = btcValue, + fiatValue = fiatValue, + satsValue = satsValue, + displayUnit = displayUnit, + ) + ) + } + + private fun observeCalculatorState() { viewModelScope.launch { - widgetsRepo.updateCalculatorValues( - calculatorValues = CalculatorValues( - fiatValue = fiatValue, - btcValue = btcValue + combine( + widgetsRepo.widgetsDataFlow + .map { it.calculatorValues } + .distinctUntilChanged(), + currencyRepo.currencyState, + ) { calculatorValues, currencyState -> + calculatorValues to currencyState + }.collect { (storedValues, currencyState) -> + val activeValues = resolveActiveValues(storedValues) + val nextValues = deriveValuesForCurrency( + activeValues = activeValues, + storedValues = storedValues, + currencyState = currencyState, ) + updateUiState(nextValues, currencyState) + } + } + } + + private fun resolveActiveValues(storedValues: CalculatorValues): CalculatorValues { + val pending = pendingValues ?: return storedValues + if (pending == storedValues) { + pendingValues = null + return storedValues + } + return pending + } + + private fun deriveValuesForCurrency( + activeValues: CalculatorValues, + storedValues: CalculatorValues, + currencyState: CurrencyState, + ): CalculatorValues { + val currencyKey = CalculatorCurrencyKey( + selectedCurrency = currencyState.selectedCurrency, + displayUnit = currencyState.displayUnit, + ) + val previousCurrencyKey = lastCurrencyKey + lastCurrencyKey = currencyKey + + val currencyChanged = previousCurrencyKey != null && previousCurrencyKey != currencyKey + val displayUnitChanged = previousCurrencyKey != null && + previousCurrencyKey.displayUnit != currencyKey.displayUnit + val isInitialSync = previousCurrencyKey == null + val nextActiveValues = deriveActiveValues( + activeValues = activeValues, + isInitialSync = isInitialSync, + displayUnitChanged = displayUnitChanged, + currencyKey = currencyKey, + ) + val shouldRefreshFiat = isInitialSync || currencyChanged || shouldHydrateFiatFromStoredBtc( + storedBtcValue = storedValues.btcValue, + storedFiatValue = storedValues.fiatValue, + currentFiatValue = nextActiveValues.fiatValue, + displayUnit = currencyState.displayUnit, + ) + + if (!shouldRefreshFiat) { + persistCanonicalValuesIfNeeded( + activeValues = activeValues, + nextActiveValues = nextActiveValues, + ) + return nextActiveValues + } + if (nextActiveValues.btcValue.isEmpty() || + isZeroBtcValue(nextActiveValues.btcValue, currencyState.displayUnit) + ) { + persistCanonicalValuesIfNeeded( + activeValues = activeValues, + nextActiveValues = nextActiveValues, ) + return nextActiveValues } + + val convertedFiat = convertSatsToFiat(nextActiveValues.resolveCalculatorSatsValue()) + if (convertedFiat.isEmpty()) { + persistCanonicalValuesIfNeeded( + activeValues = activeValues, + nextActiveValues = nextActiveValues, + ) + return nextActiveValues + } + + val updatedValues = nextActiveValues.copy(fiatValue = convertedFiat) + updateCalculatorValues(updatedValues) + return updatedValues } - companion object { - private const val SUBSCRIPTION_TIMEOUT = 5000L + private fun deriveActiveValues( + activeValues: CalculatorValues, + isInitialSync: Boolean, + displayUnitChanged: Boolean, + currencyKey: CalculatorCurrencyKey, + ): CalculatorValues { + if (activeValues.btcValue.isEmpty()) { + return activeValues.copy( + satsValue = 0L, + displayUnit = currencyKey.displayUnit, + ) + } + + val satsValue = activeValues.resolveSatsValue(currencyKey.displayUnit) + val shouldCanonicalize = shouldCanonicalizeBtcValue( + values = activeValues, + isInitialSync = isInitialSync, + displayUnitChanged = displayUnitChanged, + currencyKey = currencyKey, + ) + val btcValue = if (shouldCanonicalize) { + calculatorSatsToBtcValue(satsValue, currencyKey.displayUnit) + } else { + activeValues.btcValue + } + return activeValues.copy( + btcValue = btcValue, + satsValue = satsValue, + displayUnit = currencyKey.displayUnit, + ) + } + + private fun persistCanonicalValuesIfNeeded( + activeValues: CalculatorValues, + nextActiveValues: CalculatorValues, + ) { + if (activeValues == nextActiveValues) return + updateCalculatorValues(nextActiveValues) + } + + private fun updateCalculatorValues(calculatorValues: CalculatorValues) { + pendingValues = calculatorValues + _uiState.update { + it.copy( + btcValue = calculatorValues.btcValue, + fiatValue = calculatorValues.fiatValue, + ) + } + viewModelScope.launch { + widgetsRepo.updateCalculatorValues(calculatorValues) + } + } + + private fun CalculatorValues.resolveSatsValue(displayUnit: BitcoinDisplayUnit): Long { + if (satsValue != null || this.displayUnit != null || fiatValue.isEmpty()) { + return resolveCalculatorSatsValue() + } + val btcSatsValue = resolveCalculatorSatsValue() + val fiatSatsValue = convertFiatToSats(fiatValue) + if (shouldRecoverLegacyWholeBtcFromFiat(fiatSatsValue, displayUnit)) { + return fiatSatsValue + } + return btcSatsValue + } + + private fun CalculatorValues.shouldRecoverLegacyWholeBtcFromFiat( + fiatSatsValue: Long, + displayUnit: BitcoinDisplayUnit, + ): Boolean { + if (displayUnit.isModern()) return false + if (btcValue.any { it == '.' || it == ',' }) return false + val fiatBtcValue = calculatorSatsToBtcValue(fiatSatsValue, displayUnit).toBigDecimalOrNull() + ?: return false + val storedBtcValue = btcValue.toBigDecimalOrNull() ?: return false + return storedBtcValue.compareTo(fiatBtcValue) == 0 + } + + private fun updateUiState( + calculatorValues: CalculatorValues, + currencyState: CurrencyState, + ) { + _uiState.update { + it.copy( + btcValue = calculatorValues.btcValue, + fiatValue = calculatorValues.fiatValue, + displayUnit = currencyState.displayUnit, + currencySymbol = currencyState.currencySymbol, + selectedCurrency = currencyState.selectedCurrency, + ) + } + } + + private fun convertSatsToFiat(satsValue: Long): String { + return currencyRepo.convertSatsToFiat(sats = satsValue).getOrNull() + ?.value + ?.toCalculatorFiatRawValue() + .orEmpty() + } + + private fun convertFiatToSats(fiatValue: String): Long { + val fiatDecimal = fiatValue.toBigDecimalOrNull() ?: BigDecimal.ZERO + return currencyRepo.convertFiatToSats(fiatDecimal).getOrNull()?.toLong() ?: 0L + } +} + +@Immutable +data class CalculatorUiState( + val btcValue: String = CalculatorValues().btcValue, + val fiatValue: String = CalculatorValues().fiatValue, + val displayUnit: BitcoinDisplayUnit = BitcoinDisplayUnit.MODERN, + val currencySymbol: String = "$", + val selectedCurrency: String = "USD", +) + +private data class CalculatorCurrencyKey( + val selectedCurrency: String, + val displayUnit: BitcoinDisplayUnit, +) + +internal fun shouldHydrateFiatFromStoredBtc( + storedBtcValue: String, + storedFiatValue: String, + currentFiatValue: String, + displayUnit: BitcoinDisplayUnit, +): Boolean { + if (storedBtcValue.isEmpty()) { + return false + } + if (isZeroBtcValue(storedBtcValue, displayUnit)) { + return false + } + if (storedFiatValue.isNotEmpty()) { + return false + } + return currentFiatValue.isEmpty() +} + +internal fun isZeroBtcValue( + btcValue: String, + displayUnit: BitcoinDisplayUnit, +): Boolean = when (displayUnit) { + BitcoinDisplayUnit.MODERN -> btcValue == "0" + BitcoinDisplayUnit.CLASSIC -> btcValue.toBigDecimalOrNull()?.compareTo(BigDecimal.ZERO) == 0 +} + +private fun shouldCanonicalizeBtcValue( + values: CalculatorValues, + isInitialSync: Boolean, + displayUnitChanged: Boolean, + currencyKey: CalculatorCurrencyKey, +): Boolean { + return isInitialSync || + displayUnitChanged || + values.satsValue == null || + values.displayUnit != currencyKey.displayUnit +} + +internal fun sanitizeIntegerInput(raw: String): String { + val digits = raw.filter { it.isDigit() } + if (digits.isEmpty()) return digits + return digits.trimStart('0').ifEmpty { "0" } +} + +private fun BigDecimal.toCalculatorFiatRawValue(): String = + setScale(CALCULATOR_FIAT_DECIMAL_PLACES, RoundingMode.HALF_UP).toPlainString() + +internal fun sanitizeDecimalInput( + raw: String, + locale: Locale = Locale.getDefault(), + maxDecimalPlaces: Int? = null, +): String { + val localDecimal = DecimalFormatSymbols.getInstance(locale).decimalSeparator + val normalized = if (localDecimal == ',') raw.replace(',', '.') else raw + val filtered = normalized.filter { it.isDigit() || it == '.' } + val dotIndex = filtered.indexOf('.') + val singleDot = if (dotIndex == -1) { + filtered + } else { + filtered.substring(0, dotIndex + 1) + + filtered.substring(dotIndex + 1).replace(".", "") } + if (maxDecimalPlaces == null) return singleDot + val cappedDot = singleDot.indexOf('.') + if (cappedDot == -1) return singleDot + val fraction = singleDot.substring(cappedDot + 1) + if (fraction.length <= maxDecimalPlaces) return singleDot + return singleDot.substring(0, cappedDot + 1) + fraction.take(maxDecimalPlaces) } diff --git a/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/components/CalculatorCard.kt b/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/components/CalculatorCard.kt index 65883ab86d..336270be96 100644 --- a/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/components/CalculatorCard.kt +++ b/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/components/CalculatorCard.kt @@ -1,176 +1,364 @@ package to.bitkit.ui.screens.widgets.calculator.components +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.core.MutableTransitionState +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.material3.Icon +import androidx.compose.foundation.relocation.BringIntoViewRequester +import androidx.compose.foundation.relocation.bringIntoViewRequester +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.testTag -import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.coroutines.delay import to.bitkit.R import to.bitkit.models.BITCOIN_SYMBOL import to.bitkit.models.BitcoinDisplayUnit +import to.bitkit.models.CLASSIC_DECIMALS +import to.bitkit.models.MoneyType import to.bitkit.ui.components.BodyMSB +import to.bitkit.ui.components.KEY_DELETE +import to.bitkit.ui.components.NavBarSpacer +import to.bitkit.ui.components.NumberPad +import to.bitkit.ui.components.NumberPadType import to.bitkit.ui.components.VerticalSpacer +import to.bitkit.ui.screens.widgets.calculator.CALCULATOR_FIAT_DECIMAL_PLACES import to.bitkit.ui.screens.widgets.calculator.CalculatorViewModel +import to.bitkit.ui.screens.widgets.calculator.applyNumberPadInput +import to.bitkit.ui.screens.widgets.calculator.calculatorDecimalSeparator +import to.bitkit.ui.screens.widgets.calculator.formatBitcoinPlaceholder +import to.bitkit.ui.screens.widgets.calculator.formatBitcoinValue +import to.bitkit.ui.screens.widgets.calculator.formatFiatPlaceholder +import to.bitkit.ui.screens.widgets.calculator.formatFiatValue +import to.bitkit.ui.screens.widgets.calculator.isBtcValueInSatsRange +import to.bitkit.ui.screens.widgets.components.WidgetCardDimens import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors -import to.bitkit.ui.utils.visualTransformation.BitcoinVisualTransformation -import to.bitkit.ui.utils.visualTransformation.CalculatorFormatter -import to.bitkit.ui.utils.visualTransformation.MonetaryVisualTransformation -import to.bitkit.viewmodels.CurrencyViewModel -import java.math.BigDecimal - -private const val FIAT_DECIMAL_PLACES = 2 +import kotlin.time.Duration.Companion.milliseconds @Composable fun CalculatorCard( modifier: Modifier = Modifier, - currencyViewModel: CurrencyViewModel, + dismissNumberPadKey: Int = 0, + onInputActiveChange: (Boolean) -> Unit = {}, calculatorViewModel: CalculatorViewModel = hiltViewModel(), - showWidgetTitle: Boolean, ) { - val currencyUiState by currencyViewModel.uiState.collectAsStateWithLifecycle() - val calculatorValues by calculatorViewModel.calculatorValues.collectAsStateWithLifecycle() - var btcValue: String by rememberSaveable { mutableStateOf(calculatorValues.btcValue) } - var fiatValue: String by rememberSaveable { mutableStateOf(calculatorValues.fiatValue) } - val displayedBtcValue = btcValue.ifEmpty { calculatorValues.btcValue } - val displayedFiatValue = fiatValue - - LaunchedEffect( - calculatorValues.btcValue, - calculatorValues.fiatValue, - currencyUiState.displayUnit, - currencyUiState.selectedCurrency, - ) { - if (!shouldHydrateFiatFromStoredBtc( - storedBtcValue = calculatorValues.btcValue, - storedFiatValue = calculatorValues.fiatValue, - currentFiatValue = fiatValue, - displayUnit = currencyUiState.displayUnit, - ) - ) { - return@LaunchedEffect - } - val convertedFiat = CalculatorFormatter.convertBtcToFiat( - btcValue = calculatorValues.btcValue, - displayUnit = currencyUiState.displayUnit, - currencyViewModel = currencyViewModel, - ).orEmpty() - if (convertedFiat.isEmpty()) { - return@LaunchedEffect - } - fiatValue = convertedFiat - calculatorViewModel.updateCalculatorValues( - fiatValue = convertedFiat, - btcValue = calculatorValues.btcValue, + val uiState by calculatorViewModel.uiState.collectAsStateWithLifecycle() + + CalculatorCardEditor( + modifier = modifier, + btcPrimaryDisplayUnit = uiState.displayUnit, + btcValue = uiState.btcValue, + onBtcChange = calculatorViewModel::onBtcInputChanged, + fiatSymbol = uiState.currencySymbol, + fiatName = uiState.selectedCurrency, + fiatValue = uiState.fiatValue, + onFiatChange = calculatorViewModel::onFiatInputChanged, + dismissNumberPadKey = dismissNumberPadKey, + onInputActiveChange = onInputActiveChange, + ) +} + +@Composable +fun CalculatorCardEditor( + modifier: Modifier = Modifier, + btcPrimaryDisplayUnit: BitcoinDisplayUnit, + btcValue: String, + onBtcChange: (String) -> Unit, + fiatSymbol: String, + fiatName: String, + fiatValue: String, + onFiatChange: (String) -> Unit, + dismissNumberPadKey: Int = 0, + onInputActiveChange: (Boolean) -> Unit = {}, +) { + val numpadState = rememberNumpadState() + + Column(modifier = modifier) { + Content( + btcPrimaryDisplayUnit = btcPrimaryDisplayUnit, + btcValue = btcValue, + fiatSymbol = fiatSymbol, + fiatName = fiatName, + fiatValue = fiatValue, + activeInput = numpadState.activeInput, + onSelectInput = numpadState::selectInput, + modifier = Modifier.fillMaxWidth(), + ) + + NumpadHost( + state = numpadState, + dismissNumberPadKey = dismissNumberPadKey, + onInputActiveChange = onInputActiveChange, + btcValue = btcValue, + btcPrimaryDisplayUnit = btcPrimaryDisplayUnit, + fiatValue = fiatValue, + onBtcChange = onBtcChange, + onFiatChange = onFiatChange, ) } +} - LaunchedEffect(currencyUiState.selectedCurrency, currencyUiState.displayUnit) { - val sourceBtc = btcValue.ifEmpty { calculatorValues.btcValue } - if (sourceBtc.isEmpty() || isZeroBtcValue(sourceBtc, currencyUiState.displayUnit)) { - return@LaunchedEffect - } - val convertedFiat = CalculatorFormatter.convertBtcToFiat( - btcValue = sourceBtc, - displayUnit = currencyUiState.displayUnit, - currencyViewModel = currencyViewModel, - ).orEmpty() - if (convertedFiat.isEmpty()) { - return@LaunchedEffect +@Composable +private fun rememberNumpadState(): NumpadState { + val activeInput = rememberSaveable { mutableStateOf(null) } + val selectedInput = rememberSaveable { mutableStateOf(null) } + val visibilityState = remember { + MutableTransitionState(activeInput.value != null).apply { + targetState = activeInput.value != null } - fiatValue = convertedFiat - calculatorViewModel.updateCalculatorValues( - fiatValue = convertedFiat, - btcValue = sourceBtc, + } + val bringIntoViewRequester = remember { BringIntoViewRequester() } + + return remember(activeInput, selectedInput, visibilityState, bringIntoViewRequester) { + NumpadState( + activeInputState = activeInput, + selectedInputState = selectedInput, + visibilityState = visibilityState, + bringIntoViewRequester = bringIntoViewRequester, ) } +} - CalculatorCardContent( - modifier = modifier, - showWidgetTitle = showWidgetTitle, - btcPrimaryDisplayUnit = currencyUiState.displayUnit, - btcValue = displayedBtcValue, - onBtcChange = { rawValue -> - val sanitized = if (currencyUiState.displayUnit.isModern()) { - sanitizeIntegerInput(rawValue) - } else { - sanitizeDecimalInput(rawValue) - } - btcValue = sanitized - fiatValue = if (sanitized.isEmpty()) { - "" - } else { - CalculatorFormatter.convertBtcToFiat( - btcValue = btcValue, - displayUnit = currencyUiState.displayUnit, - currencyViewModel = currencyViewModel, - ).orEmpty() - } - calculatorViewModel.updateCalculatorValues(fiatValue = fiatValue, btcValue = btcValue) - }, - fiatSymbol = currencyUiState.currencySymbol, - fiatName = currencyUiState.selectedCurrency, - fiatValue = displayedFiatValue, - onFiatChange = { rawValue -> - val sanitized = sanitizeDecimalInput(rawValue, maxDecimalPlaces = FIAT_DECIMAL_PLACES) - fiatValue = sanitized - btcValue = if (sanitized.isEmpty()) { - "" - } else { - val converted = CalculatorFormatter.convertFiatToBtc( - fiatValue = fiatValue, - displayUnit = currencyUiState.displayUnit, - currencyViewModel = currencyViewModel, +@Stable +private class NumpadState( + activeInputState: MutableState, + selectedInputState: MutableState, + val visibilityState: MutableTransitionState, + val bringIntoViewRequester: BringIntoViewRequester, +) { + var activeInput by activeInputState + private set + + var selectedInput by selectedInputState + private set + + var errorKey by mutableStateOf(null) + private set + + fun selectInput(input: MoneyType) { + selectedInput = input + activeInput = input + visibilityState.targetState = true + clearError() + } + + fun dismiss() { + activeInput = null + visibilityState.targetState = false + clearError() + } + + fun showError(key: String) { + errorKey = key + } + + fun clearError() { + errorKey = null + } +} + +@Composable +private fun ColumnScope.NumpadHost( + state: NumpadState, + dismissNumberPadKey: Int, + onInputActiveChange: (Boolean) -> Unit, + btcValue: String, + btcPrimaryDisplayUnit: BitcoinDisplayUnit, + fiatValue: String, + onBtcChange: (String) -> Unit, + onFiatChange: (String) -> Unit, +) { + NumpadEffects( + state = state, + dismissNumberPadKey = dismissNumberPadKey, + onInputActiveChange = onInputActiveChange, + ) + + Numpad( + state = state, + btcValue = btcValue, + btcPrimaryDisplayUnit = btcPrimaryDisplayUnit, + fiatValue = fiatValue, + onBtcChange = onBtcChange, + onFiatChange = onFiatChange, + ) +} + +@Composable +private fun NumpadEffects( + state: NumpadState, + dismissNumberPadKey: Int, + onInputActiveChange: (Boolean) -> Unit, +) { + val updatedOnInputActiveChange by rememberUpdatedState(onInputActiveChange) + val isInputTargetActive = state.visibilityState.targetState + + LaunchedEffect(dismissNumberPadKey) { state.dismiss() } + + LaunchedEffect(state.activeInput, state.selectedInput) { + if (state.activeInput == null || state.selectedInput == null) return@LaunchedEffect + delay(BRING_NUMBER_PAD_INTO_VIEW_DELAY) + state.bringIntoViewRequester.bringIntoView() + } + + LaunchedEffect(isInputTargetActive) { + updatedOnInputActiveChange(isInputTargetActive) + } + + LaunchedEffect(state.errorKey) { + if (state.errorKey == null) return@LaunchedEffect + delay(ERROR_DELAY) + state.clearError() + } + + DisposableEffect(Unit) { + onDispose { updatedOnInputActiveChange(false) } + } +} + +@Composable +private fun ColumnScope.Numpad( + state: NumpadState, + btcValue: String, + btcPrimaryDisplayUnit: BitcoinDisplayUnit, + fiatValue: String, + onBtcChange: (String) -> Unit, + onFiatChange: (String) -> Unit, +) { + val selectedInput = state.selectedInput + + AnimatedVisibility( + visibleState = state.visibilityState, + enter = EnterTransition.None, + exit = fadeOut() + shrinkVertically(), + ) { + selectedInput?.let { input -> + Column( + modifier = Modifier.bringIntoViewRequester(state.bringIntoViewRequester) + ) { + VerticalSpacer(8.dp) + NumberPad( + onPress = { key -> + val currentValue = currentInputValue( + input = input, + btcValue = btcValue, + fiatValue = fiatValue, + ) + val nextValue = nextInputValue( + input = input, + key = key, + btcValue = btcValue, + btcPrimaryDisplayUnit = btcPrimaryDisplayUnit, + fiatValue = fiatValue, + ) + + if (nextValue == currentValue && key != KEY_DELETE) { + state.showError(key) + return@NumberPad + } + state.clearError() + + when (input) { + MoneyType.BITCOIN -> onBtcChange(nextValue) + MoneyType.FIAT -> onFiatChange(nextValue) + } + }, + type = when (input) { + MoneyType.BITCOIN if btcPrimaryDisplayUnit.isModern() -> NumberPadType.INTEGER + else -> NumberPadType.DECIMAL + }, + decimalSeparator = calculatorDecimalSeparator(), + errorKey = state.errorKey, + includeNavigationBarsPadding = true, + modifier = Modifier + .testTag("CalculatorNumberPad") ) - if (currencyUiState.displayUnit.isModern()) { - converted.filter { it.isDigit() } - } else { - converted - } + NavBarSpacer(modifier = Modifier.background(MaterialTheme.colorScheme.background)) } - calculatorViewModel.updateCalculatorValues(fiatValue = fiatValue, btcValue = btcValue) - }, + } + } +} + +private fun currentInputValue( + input: MoneyType, + btcValue: String, + fiatValue: String, +): String = when (input) { + MoneyType.BITCOIN -> btcValue + MoneyType.FIAT -> fiatValue +} + +private fun nextInputValue( + input: MoneyType, + key: String, + btcValue: String, + btcPrimaryDisplayUnit: BitcoinDisplayUnit, + fiatValue: String, +): String = when (input) { + MoneyType.BITCOIN -> { + val nextValue = applyNumberPadInput( + rawValue = btcValue, + key = key, + maxDecimalPlaces = CLASSIC_DECIMALS.takeUnless { + btcPrimaryDisplayUnit.isModern() + }, + ) + if (isBtcValueInSatsRange(nextValue, btcPrimaryDisplayUnit)) { + nextValue + } else { + btcValue + } + } + + MoneyType.FIAT -> applyNumberPadInput( + rawValue = fiatValue, + key = key, + maxDecimalPlaces = CALCULATOR_FIAT_DECIMAL_PLACES, ) } @Composable -fun CalculatorCardContent( +private fun Content( modifier: Modifier = Modifier, - showWidgetTitle: Boolean, btcPrimaryDisplayUnit: BitcoinDisplayUnit, btcValue: String, - onBtcChange: (String) -> Unit, fiatSymbol: String, fiatName: String, fiatValue: String, - onFiatChange: (String) -> Unit, + activeInput: MoneyType? = null, + onSelectInput: (MoneyType) -> Unit = {}, ) { Box( modifier = modifier @@ -182,118 +370,152 @@ fun CalculatorCardContent( .fillMaxWidth() .padding(16.dp) ) { - if (showWidgetTitle) { - WidgetTitleRow() - Spacer(modifier = Modifier.height(16.dp)) - } - - // Bitcoin input with visual transformation CalculatorInput( - value = btcValue, - onValueChange = onBtcChange, + value = formatBitcoinValue(btcValue, btcPrimaryDisplayUnit), currencySymbol = BITCOIN_SYMBOL, currencyName = stringResource(R.string.settings__general__unit_bitcoin), - keyboardType = if (btcPrimaryDisplayUnit.isModern()) KeyboardType.Number else KeyboardType.Decimal, - visualTransformation = BitcoinVisualTransformation(btcPrimaryDisplayUnit), - modifier = Modifier.fillMaxWidth() + isActive = activeInput == MoneyType.BITCOIN, + onClick = { onSelectInput(MoneyType.BITCOIN) }, + placeholder = formatBitcoinPlaceholder(btcValue, btcPrimaryDisplayUnit), + modifier = Modifier + .fillMaxWidth() + .testTag("CalculatorBtcInput") ) VerticalSpacer(16.dp) - // Fiat input with decimal transformation CalculatorInput( - value = fiatValue, - onValueChange = onFiatChange, + value = formatFiatValue(fiatValue), currencySymbol = fiatSymbol, currencyName = fiatName, - keyboardType = KeyboardType.Decimal, - visualTransformation = MonetaryVisualTransformation(decimalPlaces = FIAT_DECIMAL_PLACES), - modifier = Modifier.fillMaxWidth() + isActive = activeInput == MoneyType.FIAT, + onClick = { onSelectInput(MoneyType.FIAT) }, + placeholder = formatFiatPlaceholder(fiatValue), + modifier = Modifier + .fillMaxWidth() + .testTag("CalculatorFiatInput") ) } } } -internal fun shouldHydrateFiatFromStoredBtc( - storedBtcValue: String, - storedFiatValue: String, - currentFiatValue: String, - displayUnit: BitcoinDisplayUnit, -): Boolean { - if (storedBtcValue.isEmpty()) { - return false - } - if (isZeroBtcValue(storedBtcValue, displayUnit)) { - return false - } - if (storedFiatValue.isNotEmpty()) { - return false - } - return currentFiatValue.isEmpty() -} +private val BRING_NUMBER_PAD_INTO_VIEW_DELAY = 120.milliseconds +private val ERROR_DELAY = 500.milliseconds -internal fun isZeroBtcValue( +@Composable +fun CalculatorCardSmall( + btcPrimaryDisplayUnit: BitcoinDisplayUnit, btcValue: String, - displayUnit: BitcoinDisplayUnit, -): Boolean = when (displayUnit) { - BitcoinDisplayUnit.MODERN -> btcValue == "0" - BitcoinDisplayUnit.CLASSIC -> btcValue.toBigDecimalOrNull()?.compareTo(BigDecimal.ZERO) == 0 + fiatSymbol: String, + fiatValue: String, + modifier: Modifier = Modifier, +) { + Column( + verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically), + modifier = modifier + .size(WidgetCardDimens.COMPACT_CARD_SIZE) + .clip(shape = MaterialTheme.shapes.medium) + .background(Colors.Gray6) + .padding(16.dp) + .testTag("calculator_card_small") + ) { + ReadOnlyRow( + currencySymbol = BITCOIN_SYMBOL, + value = formatBitcoinValue(btcValue, btcPrimaryDisplayUnit), + iconSize = 24.dp, + rowPadding = 12.dp, + modifier = Modifier + .fillMaxWidth() + .testTag("CalculatorSmallBtcRow") + ) + ReadOnlyRow( + currencySymbol = fiatSymbol, + value = formatFiatValue(fiatValue), + iconSize = 24.dp, + rowPadding = 12.dp, + modifier = Modifier + .fillMaxWidth() + .testTag("CalculatorSmallFiatRow") + ) + } } @Composable -private fun WidgetTitleRow() { +private fun ReadOnlyRow( + currencySymbol: String, + value: String, + iconSize: Dp, + rowPadding: Dp, + modifier: Modifier = Modifier, +) { + val displayCurrencySymbol = currencySymbol.toCalculatorDisplaySymbol() + Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.testTag("widget_title_row") + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier + .clip(MaterialTheme.shapes.small) + .background(Colors.Black) + .padding(rowPadding) ) { - Icon( - painter = painterResource(R.drawable.widget_math_operation), - contentDescription = null, + Box( + contentAlignment = Alignment.Center, modifier = Modifier - .size(32.dp) - .testTag("widget_title_icon"), - tint = Color.Unspecified - ) - Spacer(modifier = Modifier.width(16.dp)) + .background(color = Colors.Gray6, shape = CircleShape) + .size(iconSize) + ) { + BodyMSB( + text = displayCurrencySymbol, + color = Colors.Brand, + textAlign = TextAlign.Center, + maxLines = 1, + ) + } BodyMSB( - text = stringResource(R.string.widgets__calculator__name), - modifier = Modifier.testTag("widget_title_text") + text = value, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) ) } } -@Preview(showBackground = true) +@Preview @Composable private fun Preview() { AppThemeSurface { Column( + verticalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier - .fillMaxSize() - .padding(horizontal = 16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) + .padding(16.dp) ) { - CalculatorCardContent( - modifier = Modifier.fillMaxWidth(), - showWidgetTitle = true, - btcValue = "1800000000", // Will display as "1 800 000 000" in MODERN mode + CalculatorCardEditor( + btcValue = "1800000000", onBtcChange = {}, fiatSymbol = "$", fiatValue = "4.55", fiatName = "USD", onFiatChange = {}, - btcPrimaryDisplayUnit = BitcoinDisplayUnit.MODERN + btcPrimaryDisplayUnit = BitcoinDisplayUnit.MODERN, + modifier = Modifier.fillMaxWidth() ) - CalculatorCardContent( - modifier = Modifier.fillMaxWidth(), - showWidgetTitle = false, - btcValue = "22200000", // Will display as "0.22200000" in CLASSIC mode + CalculatorCardEditor( + btcValue = "22200000", onBtcChange = {}, fiatSymbol = "$", fiatValue = "4.55", fiatName = "USD", onFiatChange = {}, - btcPrimaryDisplayUnit = BitcoinDisplayUnit.CLASSIC + btcPrimaryDisplayUnit = BitcoinDisplayUnit.CLASSIC, + modifier = Modifier.fillMaxWidth() + ) + + CalculatorCardSmall( + btcValue = "10000", + fiatValue = "6.25", + fiatSymbol = "$", + btcPrimaryDisplayUnit = BitcoinDisplayUnit.MODERN, ) } } diff --git a/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/components/CalculatorInput.kt b/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/components/CalculatorInput.kt index d7f646d7f8..7d95d8361f 100644 --- a/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/components/CalculatorInput.kt +++ b/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/components/CalculatorInput.kt @@ -4,94 +4,161 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text 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 androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withStyle import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp +import kotlinx.coroutines.delay import to.bitkit.ui.components.BodyMSB import to.bitkit.ui.components.CaptionB -import to.bitkit.ui.components.TextInput -import to.bitkit.ui.theme.AppTextFieldDefaults +import to.bitkit.ui.shared.modifiers.clickableAlpha +import to.bitkit.ui.theme.AppTextStyles import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors -import java.text.DecimalFormatSymbols -import java.util.Locale +import kotlin.math.roundToInt +import kotlin.time.Duration.Companion.milliseconds + +val inputShape @Composable get() = MaterialTheme.shapes.small +private val CURSOR_WIDTH = 2.dp +private val CURSOR_HEIGHT = 22.dp +private val CURSOR_BLINK_INTERVAL = 500.milliseconds @Composable fun CalculatorInput( value: String, - onValueChange: (String) -> Unit, currencySymbol: String, currencyName: String, + isActive: Boolean, + onClick: () -> Unit, modifier: Modifier = Modifier, - keyboardType: KeyboardType = KeyboardType.Number, - visualTransformation: VisualTransformation = VisualTransformation.None, + placeholder: String = "", ) { val displayCurrencySymbol = currencySymbol.toCalculatorDisplaySymbol() - TextInput( - value = value, - singleLine = true, - onValueChange = onValueChange, - leadingIcon = { - Box( - contentAlignment = Alignment.Center, - modifier = Modifier - .background(color = Colors.Gray6, shape = CircleShape) - .size(32.dp) - ) { - BodyMSB(displayCurrencySymbol, color = Colors.Brand) - } - }, - keyboardOptions = KeyboardOptions( - keyboardType = keyboardType, - ), - suffix = { CaptionB(currencyName.uppercase(), color = Colors.Gray1) }, - colors = AppTextFieldDefaults.noIndicatorColors.copy( - focusedContainerColor = Colors.Black, - unfocusedContainerColor = Colors.Black - ), - visualTransformation = visualTransformation, + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = modifier - ) + .clip(inputShape) + .background(Colors.Black) + .clickableAlpha(onClick = onClick) + .padding(16.dp) + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .background(color = Colors.Gray6, shape = CircleShape) + .size(32.dp) + ) { + BodyMSB(displayCurrencySymbol, color = Colors.Brand) + } + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.weight(1f) + ) { + InputValue( + value = value, + placeholder = placeholder, + isActive = isActive, + modifier = Modifier.weight(weight = 1f, fill = false) + ) + } + CaptionB( + text = currencyName.uppercase(), + color = Colors.Gray1, + maxLines = 1, + ) + } } -internal fun sanitizeIntegerInput(raw: String): String { - val digits = raw.filter { it.isDigit() } - if (digits.isEmpty()) return digits - return digits.trimStart('0').ifEmpty { "0" } +@Composable +private fun InputValue( + value: String, + placeholder: String, + isActive: Boolean, + modifier: Modifier = Modifier, +) { + var textLayout by remember { mutableStateOf(null) } + val text = remember(value, placeholder) { + buildAnnotatedString { + append(value) + withStyle(SpanStyle(color = Colors.White50)) { + append(placeholder) + } + } + } + + Box(modifier = modifier) { + Text( + text = text, + style = AppTextStyles.BodyMSB.merge( + color = MaterialTheme.colorScheme.primary, + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + onTextLayout = { textLayout = it }, + ) + if (isActive) { + Cursor( + modifier = Modifier + .align(Alignment.CenterStart) + .offset { + IntOffset( + x = textLayout.cursorOffset(value).roundToInt(), + y = 0, + ) + } + ) + } + } } -internal fun sanitizeDecimalInput( - raw: String, - locale: Locale = Locale.getDefault(), - maxDecimalPlaces: Int? = null, -): String { - val localDecimal = DecimalFormatSymbols.getInstance(locale).decimalSeparator - val normalized = if (localDecimal == ',') raw.replace(',', '.') else raw - val filtered = normalized.filter { it.isDigit() || it == '.' } - val dotIndex = filtered.indexOf('.') - val singleDot = if (dotIndex == -1) { - filtered - } else { - filtered.substring(0, dotIndex + 1) + - filtered.substring(dotIndex + 1).replace(".", "") +private fun TextLayoutResult?.cursorOffset(value: String): Float { + if (this == null || value.isEmpty()) return 0f + return getCursorRect(value.length).left +} + +@Composable +private fun Cursor(modifier: Modifier = Modifier) { + var isVisible by remember { mutableStateOf(true) } + + LaunchedEffect(Unit) { + while (true) { + delay(CURSOR_BLINK_INTERVAL) + isVisible = !isVisible + } } - if (maxDecimalPlaces == null) return singleDot - val cappedDot = singleDot.indexOf('.') - if (cappedDot == -1) return singleDot - val fraction = singleDot.substring(cappedDot + 1) - if (fraction.length <= maxDecimalPlaces) return singleDot - return singleDot.substring(0, cappedDot + 1) + fraction.take(maxDecimalPlaces) + + Box( + modifier = modifier + .width(CURSOR_WIDTH) + .height(CURSOR_HEIGHT) + .background(if (isVisible) Colors.Brand else Color.Transparent) + ) } internal fun String.toCalculatorDisplaySymbol(): String { @@ -103,28 +170,29 @@ internal fun String.toCalculatorDisplaySymbol(): String { } } -@Preview(showBackground = true) +@Preview @Composable private fun Preview() { AppThemeSurface { Column( verticalArrangement = Arrangement.spacedBy(16.dp), - modifier = Modifier - .fillMaxSize() - .padding(16.dp) + modifier = Modifier.padding(16.dp) ) { CalculatorInput( - value = "100000", - onValueChange = {}, + value = "123 456 789", currencySymbol = "₿", currencyName = "BITCOIN", + isActive = true, + onClick = {}, modifier = Modifier.fillMaxWidth() ) CalculatorInput( - value = "4.55", - onValueChange = {}, + value = "82,209.8", currencySymbol = "$", currencyName = "USD", + isActive = true, + onClick = {}, + placeholder = "0", modifier = Modifier.fillMaxWidth() ) } diff --git a/app/src/main/java/to/bitkit/ui/utils/visualTransformation/BitcoinVisualTransformation.kt b/app/src/main/java/to/bitkit/ui/utils/visualTransformation/BitcoinVisualTransformation.kt deleted file mode 100644 index ccf9c1474b..0000000000 --- a/app/src/main/java/to/bitkit/ui/utils/visualTransformation/BitcoinVisualTransformation.kt +++ /dev/null @@ -1,140 +0,0 @@ -package to.bitkit.ui.utils.visualTransformation - -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.input.OffsetMapping -import androidx.compose.ui.text.input.TransformedText -import androidx.compose.ui.text.input.VisualTransformation -import to.bitkit.models.BitcoinDisplayUnit -import to.bitkit.models.SATS_GROUPING_SEPARATOR -import java.text.DecimalFormat -import java.text.DecimalFormatSymbols -import java.util.Locale - -class BitcoinVisualTransformation( - private val displayUnit: BitcoinDisplayUnit -) : VisualTransformation { - - override fun filter(text: AnnotatedString): TransformedText { - val rawText = text.text - val sanitizedText = sanitizeInput(rawText) - - if (sanitizedText.isEmpty()) { - return TransformedText(AnnotatedString(""), OffsetMapping.Identity) - } - - val formattedText = when (displayUnit) { - BitcoinDisplayUnit.MODERN -> formatModernDisplay(sanitizedText) - BitcoinDisplayUnit.CLASSIC -> formatClassicDisplay(sanitizedText) - } - - return TransformedText( - AnnotatedString(formattedText), - createOffsetMapping(rawText, formattedText), - ) - } - - private fun sanitizeInput(text: String): String = when (displayUnit) { - BitcoinDisplayUnit.MODERN -> text.filter { it.isDigit() } - BitcoinDisplayUnit.CLASSIC -> sanitizeClassicInput(text) - } - - private fun sanitizeClassicInput(text: String, locale: Locale = Locale.getDefault()): String { - val localDecimal = DecimalFormatSymbols.getInstance(locale).decimalSeparator - val normalized = if (localDecimal == ',') text.replace(',', '.') else text - val filtered = normalized.filter { it.isDigit() || it == '.' } - val dotIndex = filtered.indexOf('.') - if (dotIndex == -1) { - return filtered - } - return filtered.substring(0, dotIndex + 1) + - filtered.substring(dotIndex + 1).replace(".", "") - } - - private fun formatModernDisplay(text: String): String { - val digits = text.replace("$SATS_GROUPING_SEPARATOR", "") - if (digits.isEmpty()) { - return "" - } - val normalizedDigits = digits.trimStart('0').ifEmpty { "0" } - return normalizedDigits.reversed().chunked(3).joinToString(" ").reversed() - } - - private fun formatClassicDisplay(text: String): String { - val cleanText = text.replace(" ", "").replace(",", "") - if (cleanText.isEmpty() || cleanText == ".") { - return cleanText - } - - val endsWithDecimal = cleanText.endsWith(".") - val textToFormat = if (endsWithDecimal) cleanText.dropLast(1) else cleanText - if (textToFormat.isEmpty()) { - return cleanText - } - - val doubleValue = textToFormat.toDoubleOrNull() ?: return cleanText - - val formatSymbols = DecimalFormatSymbols(Locale.getDefault()).apply { - groupingSeparator = ' ' - decimalSeparator = '.' - } - val formatter = DecimalFormat("#,##0.########", formatSymbols) - val formatted = formatter.format(doubleValue) - return if (endsWithDecimal) "$formatted." else formatted - } - - private fun createOffsetMapping(rawOriginal: String, transformed: String): OffsetMapping { - val rawToSanitizedCount = IntArray(rawOriginal.length + 1) - var dotSeen = false - var sanitizedSoFar = 0 - for (i in rawOriginal.indices) { - val char = rawOriginal[i] - val isKept = when { - displayUnit == BitcoinDisplayUnit.MODERN -> char.isDigit() - char.isDigit() -> true - char == '.' && !dotSeen -> { - dotSeen = true - true - } - else -> false - } - if (isKept) sanitizedSoFar++ - rawToSanitizedCount[i + 1] = sanitizedSoFar - } - val totalSanitized = sanitizedSoFar - val transformedNonSpaceCount = transformed.count { it != ' ' } - // MODERN mode strips leading zeros via formatModernDisplay; account for that gap so - // cursor positions over stripped raw digits collapse to the start of the displayed text. - val leadingStripped = when (displayUnit) { - BitcoinDisplayUnit.MODERN -> (totalSanitized - transformedNonSpaceCount).coerceAtLeast(0) - BitcoinDisplayUnit.CLASSIC -> 0 - } - - return object : OffsetMapping { - override fun originalToTransformed(offset: Int): Int { - val clamped = offset.coerceIn(0, rawOriginal.length) - val validCount = (rawToSanitizedCount[clamped] - leadingStripped).coerceAtLeast(0) - if (validCount >= transformedNonSpaceCount) return transformed.length - var transformedOffset = 0 - var counted = 0 - while (transformedOffset < transformed.length && counted < validCount) { - if (transformed[transformedOffset] != ' ') counted++ - transformedOffset++ - } - while (transformedOffset < transformed.length && transformed[transformedOffset] == ' ') { - transformedOffset++ - } - return transformedOffset - } - - override fun transformedToOriginal(offset: Int): Int { - val clamped = offset.coerceIn(0, transformed.length) - if (clamped >= transformed.length) return rawOriginal.length - val validCount = transformed.take(clamped).count { it != ' ' } + leadingStripped - for (i in 0..rawOriginal.length) { - if (rawToSanitizedCount[i] >= validCount) return i - } - return rawOriginal.length - } - } - } -} diff --git a/app/src/main/java/to/bitkit/ui/utils/visualTransformation/CalculatorFormatter.kt b/app/src/main/java/to/bitkit/ui/utils/visualTransformation/CalculatorFormatter.kt deleted file mode 100644 index a20270fe3d..0000000000 --- a/app/src/main/java/to/bitkit/ui/utils/visualTransformation/CalculatorFormatter.kt +++ /dev/null @@ -1,56 +0,0 @@ -package to.bitkit.ui.utils.visualTransformation - -import to.bitkit.ext.removeSpaces -import to.bitkit.ext.toLongOrDefault -import to.bitkit.models.BitcoinDisplayUnit -import to.bitkit.models.CLASSIC_DECIMALS -import to.bitkit.models.SATS_IN_BTC -import to.bitkit.models.asBtc -import to.bitkit.models.formatCurrency -import to.bitkit.models.formatToModernDisplay -import to.bitkit.viewmodels.CurrencyViewModel -import java.math.BigDecimal -import java.math.RoundingMode - -object CalculatorFormatter { - - fun convertBtcToFiat( - btcValue: String, - displayUnit: BitcoinDisplayUnit, - currencyViewModel: CurrencyViewModel, - ): String? { - val satsOrBtc = btcValue.removeSpaces() - val satsLong = when (displayUnit) { - BitcoinDisplayUnit.MODERN -> satsOrBtc.toLongOrDefault() - BitcoinDisplayUnit.CLASSIC -> { - val btcDecimal = BigDecimal.valueOf(satsOrBtc.toDoubleOrNull() ?: 0.0) - val satsDecimal = btcDecimal.multiply(BigDecimal(SATS_IN_BTC)) - val roundedNumber = satsDecimal.setScale(0, RoundingMode.HALF_UP) - roundedNumber.toLong() - } - } - - val fiat = currencyViewModel.convert(sats = satsLong) - return fiat?.formatted - } - - fun convertFiatToBtc( - fiatValue: String, - displayUnit: BitcoinDisplayUnit, - currencyViewModel: CurrencyViewModel, - ): String { - val satsValue = currencyViewModel.convertFiatToSats(fiatValue.toDoubleOrNull() ?: 0.0) - - return when (displayUnit) { - BitcoinDisplayUnit.MODERN -> { - satsValue.formatToModernDisplay() - } - - BitcoinDisplayUnit.CLASSIC -> { - satsValue.asBtc() - .formatCurrency(decimalPlaces = CLASSIC_DECIMALS) - .orEmpty() - } - } - } -} diff --git a/app/src/main/java/to/bitkit/ui/utils/visualTransformation/MonetaryVisualTransformation.kt b/app/src/main/java/to/bitkit/ui/utils/visualTransformation/MonetaryVisualTransformation.kt deleted file mode 100644 index 25ae408ff6..0000000000 --- a/app/src/main/java/to/bitkit/ui/utils/visualTransformation/MonetaryVisualTransformation.kt +++ /dev/null @@ -1,171 +0,0 @@ -package to.bitkit.ui.utils.visualTransformation - -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.input.OffsetMapping -import androidx.compose.ui.text.input.TransformedText -import androidx.compose.ui.text.input.VisualTransformation -import java.text.DecimalFormat -import java.text.DecimalFormatSymbols -import java.util.Locale -import kotlin.text.iterator - -class MonetaryVisualTransformation( - private val decimalPlaces: Int = 2, -) : VisualTransformation { - - companion object { - private const val GROUPING_SEPARATOR = ' ' - } - - override fun filter(text: AnnotatedString): TransformedText { - val originalText = text.text - - if (originalText.isEmpty()) { - return TransformedText(text, OffsetMapping.Identity) - } - - // Limit decimal places before formatting - val limitedText = limitDecimalPlaces(originalText) - val formattedText = formatMonetaryValue(limitedText) - val offsetMapping = createOffsetMapping(limitedText, formattedText) - - return TransformedText( - AnnotatedString(formattedText), - offsetMapping - ) - } - - private fun limitDecimalPlaces(text: String): String { - val cleanText = text.replace(",", "").replace("$GROUPING_SEPARATOR", "") - - val decimalIndex = cleanText.indexOf('.') - if (decimalIndex == -1) { - return cleanText - } - - val integerPart = cleanText.substring(0, decimalIndex) - val decimalPart = cleanText.substring(decimalIndex + 1) - - // Limit decimal part to specified places - val limitedDecimalPart = decimalPart.take(decimalPlaces) - - return if (limitedDecimalPart.isEmpty() && cleanText.endsWith(".")) { - "$integerPart." - } else if (limitedDecimalPart.isEmpty()) { - integerPart - } else { - "$integerPart.$limitedDecimalPart" - } - } - - private fun formatMonetaryValue(text: String): String { - // Handle cases where user is typing a decimal point - if (text.isEmpty() || text == ".") { - return text - } - - // If text ends with a decimal point, preserve it - val endsWithDecimal = text.endsWith(".") - val textToFormat = if (endsWithDecimal) text.dropLast(1) else text - - // If the text to format is empty after removing the decimal, return original - if (textToFormat.isEmpty()) { - return text - } - - val doubleValue = textToFormat.toDoubleOrNull() ?: return text - - val formatSymbols = DecimalFormatSymbols(Locale.getDefault()).apply { - groupingSeparator = GROUPING_SEPARATOR - decimalSeparator = '.' - } - - val formatter = if (endsWithDecimal) { - DecimalFormat("#,##0", formatSymbols) - } else { - val decimalPlacesPattern = "#".repeat(decimalPlaces) - DecimalFormat("#,##0.$decimalPlacesPattern", formatSymbols).apply { - minimumFractionDigits = 0 - maximumFractionDigits = decimalPlaces - } - } - - val formatted = formatter.format(doubleValue) - return if (endsWithDecimal) "$formatted." else formatted - } - - @Suppress("NestedBlockDepth", "LoopWithTooManyJumpStatements") - private fun createOffsetMapping(original: String, transformed: String): OffsetMapping { - return object : OffsetMapping { - override fun originalToTransformed(offset: Int): Int { - if (offset <= 0) return 0 - if (offset >= original.length) return transformed.length - - val originalSubstring = original.take(offset) - var transformedOffset = 0 - var originalIndex = 0 - - for (char in transformed) { - if (originalIndex >= originalSubstring.length) break - - if (char == GROUPING_SEPARATOR) { - transformedOffset++ - } else if (originalIndex < originalSubstring.length && - originalSubstring[originalIndex] == char - ) { - // Characters match, advance both - originalIndex++ - transformedOffset++ - } else { - // Look for next matching character in original - var found = false - for (i in originalIndex until originalSubstring.length) { - if (originalSubstring[i] == char) { - originalIndex = i + 1 - transformedOffset++ - found = true - break - } - } - if (!found) break - } - } - - return transformedOffset.coerceAtMost(transformed.length) - } - - override fun transformedToOriginal(offset: Int): Int { - if (offset <= 0) return 0 - if (offset >= transformed.length) return original.length - - val transformedSubstring = transformed.take(offset) - var originalOffset = 0 - var transformedIndex = 0 - - for (char in original) { - if (transformedIndex >= transformedSubstring.length) break - - if (char == transformedSubstring[transformedIndex]) { - // Characters match, advance both - transformedIndex++ - originalOffset++ - } else if (transformedIndex < transformedSubstring.length - 1 && - transformedSubstring[transformedIndex] == GROUPING_SEPARATOR - ) { - transformedIndex++ - if (transformedIndex < transformedSubstring.length && - char == transformedSubstring[transformedIndex] - ) { - transformedIndex++ - originalOffset++ - } - } else { - originalOffset++ - } - } - - return originalOffset.coerceAtMost(original.length) - } - } - } -} diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 2e95d70d68..0a95e483e5 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -2683,6 +2683,8 @@ class AppViewModel @Inject constructor( } } + fun handleLaunchIntent(intent: Intent) = handleDeeplinkIntent(intent) + fun clearPendingPubkyImport() { viewModelScope.launch { pubkyRepo.clearPendingImport() diff --git a/app/src/test/java/to/bitkit/ui/screens/widgets/calculator/CalculatorViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/widgets/calculator/CalculatorViewModelTest.kt new file mode 100644 index 0000000000..618ddfd69c --- /dev/null +++ b/app/src/test/java/to/bitkit/ui/screens/widgets/calculator/CalculatorViewModelTest.kt @@ -0,0 +1,382 @@ +package to.bitkit.ui.screens.widgets.calculator + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.advanceUntilIdle +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import to.bitkit.data.WidgetsData +import to.bitkit.models.BitcoinDisplayUnit +import to.bitkit.models.ConvertedAmount +import to.bitkit.models.widget.CalculatorValues +import to.bitkit.repositories.CurrencyRepo +import to.bitkit.repositories.CurrencyState +import to.bitkit.repositories.WidgetsRepo +import to.bitkit.test.BaseUnitTest +import java.math.BigDecimal +import java.util.Locale +import kotlin.test.assertEquals + +@OptIn(ExperimentalCoroutinesApi::class) +class CalculatorViewModelTest : BaseUnitTest() { + + private val widgetsRepo: WidgetsRepo = mock() + private val currencyRepo: CurrencyRepo = mock() + private val widgetsData = MutableStateFlow(WidgetsData()) + private val currencyState = MutableStateFlow(CurrencyState()) + private var lastConvertedSats = 0L + private var fiatConversionValue: String? = null + private var fiatConversionFormatted: String? = null + private var fiatToSatsValue = 12_345uL + + private lateinit var sut: CalculatorViewModel + + @Before + fun setUp() { + Locale.setDefault(Locale.US) + widgetsData.value = WidgetsData() + currencyState.value = CurrencyState() + lastConvertedSats = 0L + fiatConversionValue = null + fiatConversionFormatted = null + fiatToSatsValue = 12_345uL + + whenever(widgetsRepo.widgetsDataFlow).thenReturn(widgetsData) + whenever(currencyRepo.currencyState).thenReturn(currencyState) + whenever(currencyRepo.convertSatsToFiat(any(), anyOrNull())).thenAnswer { + val sats = it.getArgument(0) + lastConvertedSats = sats + val fiatValue = fiatConversionValue ?: currentFiatValue() + ConvertedAmount( + value = BigDecimal(fiatValue), + formatted = fiatConversionFormatted ?: fiatValue, + symbol = currencyState.value.currencySymbol, + currency = currencyState.value.selectedCurrency, + flag = "", + sats = sats, + ) + } + whenever(currencyRepo.convertFiatToSats(any(), anyOrNull())).thenAnswer { fiatToSatsValue } + whenever { widgetsRepo.updateCalculatorValues(any()) }.thenAnswer { + val calculatorValues = it.getArgument(0) + widgetsData.value = widgetsData.value.copy(calculatorValues = calculatorValues) + Unit + } + } + + @Test + fun `init backfills sats and hydrates fiat from legacy btc`() = test { + widgetsData.value = WidgetsData( + calculatorValues = CalculatorValues( + btcValue = "10000", + fiatValue = "", + ) + ) + sut = createSut() + advanceUntilIdle() + + assertEquals("10000", sut.uiState.value.btcValue) + assertEquals("6.25", sut.uiState.value.fiatValue) + assertEquals(10_000L, widgetsData.value.calculatorValues.satsValue) + assertEquals(BitcoinDisplayUnit.MODERN, widgetsData.value.calculatorValues.displayUnit) + assertEquals("6.25", widgetsData.value.calculatorValues.fiatValue) + } + + @Test + fun `init refreshes fiat value when stored fiat already exists`() = test { + widgetsData.value = WidgetsData( + calculatorValues = CalculatorValues( + btcValue = "10000", + fiatValue = "1.00", + satsValue = 10_000L, + displayUnit = BitcoinDisplayUnit.MODERN, + ) + ) + sut = createSut() + advanceUntilIdle() + + assertEquals("10000", sut.uiState.value.btcValue) + assertEquals("6.25", sut.uiState.value.fiatValue) + assertEquals("6.25", widgetsData.value.calculatorValues.fiatValue) + } + + @Test + fun `onBtcInputChanged sanitizes converts and persists values`() = test { + sut = createSut() + advanceUntilIdle() + + sut.onBtcInputChanged("0888,,,,,,,.00000000") + advanceUntilIdle() + + assertEquals("88800000000", sut.uiState.value.btcValue) + assertEquals("6.25", sut.uiState.value.fiatValue) + assertEquals( + CalculatorValues( + btcValue = "88800000000", + fiatValue = "6.25", + satsValue = 88_800_000_000L, + displayUnit = BitcoinDisplayUnit.MODERN, + ), + widgetsData.value.calculatorValues, + ) + } + + @Test + fun `onBtcInputChanged clears both values when input is empty`() = test { + sut = createSut() + advanceUntilIdle() + + sut.onBtcInputChanged("") + advanceUntilIdle() + + assertEquals("", sut.uiState.value.btcValue) + assertEquals("", sut.uiState.value.fiatValue) + assertEquals( + CalculatorValues( + btcValue = "", + fiatValue = "", + satsValue = 0L, + displayUnit = BitcoinDisplayUnit.MODERN, + ), + widgetsData.value.calculatorValues, + ) + } + + @Test + fun `onBtcInputChanged converts classic btc input to sats`() = test { + currencyState.value = CurrencyState(displayUnit = BitcoinDisplayUnit.CLASSIC) + sut = createSut() + advanceUntilIdle() + + sut.onBtcInputChanged("0.00010000") + advanceUntilIdle() + + assertEquals("0.00010000", sut.uiState.value.btcValue) + assertEquals("6.25", sut.uiState.value.fiatValue) + assertEquals(10_000L, lastConvertedSats) + assertEquals(10_000L, widgetsData.value.calculatorValues.satsValue) + assertEquals(BitcoinDisplayUnit.CLASSIC, widgetsData.value.calculatorValues.displayUnit) + } + + @Test + fun `onBtcInputChanged preserves classic btc text while editing`() = test { + currencyState.value = CurrencyState(displayUnit = BitcoinDisplayUnit.CLASSIC) + sut = createSut() + advanceUntilIdle() + + sut.onBtcInputChanged("0.") + advanceUntilIdle() + + assertEquals("0.", sut.uiState.value.btcValue) + assertEquals("0.", widgetsData.value.calculatorValues.btcValue) + assertEquals(0L, widgetsData.value.calculatorValues.satsValue) + + sut.onBtcInputChanged("0.1") + advanceUntilIdle() + + assertEquals("0.1", sut.uiState.value.btcValue) + assertEquals("0.1", widgetsData.value.calculatorValues.btcValue) + assertEquals(10_000_000L, widgetsData.value.calculatorValues.satsValue) + } + + @Test + fun `onBtcInputChanged stores fiat conversion without grouping`() = test { + fiatConversionValue = "39621.05" + fiatConversionFormatted = "39,621.05" + sut = createSut() + advanceUntilIdle() + + sut.onBtcInputChanged("59500000") + advanceUntilIdle() + + assertEquals("39621.05", sut.uiState.value.fiatValue) + assertEquals("39621.05", widgetsData.value.calculatorValues.fiatValue) + } + + @Test + fun `onFiatInputChanged sanitizes converts and persists values`() = test { + sut = createSut() + advanceUntilIdle() + + sut.onFiatInputChanged("12.345") + advanceUntilIdle() + + assertEquals("12345", sut.uiState.value.btcValue) + assertEquals("12.34", sut.uiState.value.fiatValue) + assertEquals( + CalculatorValues( + btcValue = "12345", + fiatValue = "12.34", + satsValue = 12_345L, + displayUnit = BitcoinDisplayUnit.MODERN, + ), + widgetsData.value.calculatorValues, + ) + } + + @Test + fun `onFiatInputChanged clears both values when input is empty`() = test { + sut = createSut() + advanceUntilIdle() + + sut.onFiatInputChanged("") + advanceUntilIdle() + + assertEquals("", sut.uiState.value.btcValue) + assertEquals("", sut.uiState.value.fiatValue) + assertEquals( + CalculatorValues( + btcValue = "", + fiatValue = "", + satsValue = 0L, + displayUnit = BitcoinDisplayUnit.MODERN, + ), + widgetsData.value.calculatorValues, + ) + } + + @Test + fun `currency change refreshes fiat from active btc value`() = test { + widgetsData.value = WidgetsData( + calculatorValues = CalculatorValues( + btcValue = "10000", + fiatValue = "6.25", + satsValue = 10_000L, + displayUnit = BitcoinDisplayUnit.MODERN, + ) + ) + sut = createSut() + advanceUntilIdle() + + currencyState.value = CurrencyState( + selectedCurrency = "EUR", + currencySymbol = "EUR", + displayUnit = BitcoinDisplayUnit.MODERN, + ) + advanceUntilIdle() + + assertEquals("10000", sut.uiState.value.btcValue) + assertEquals("5.50", sut.uiState.value.fiatValue) + assertEquals("EUR", sut.uiState.value.selectedCurrency) + assertEquals("EUR", sut.uiState.value.currencySymbol) + assertEquals("5.50", widgetsData.value.calculatorValues.fiatValue) + assertEquals(BitcoinDisplayUnit.MODERN, widgetsData.value.calculatorValues.displayUnit) + } + + @Test + fun `display unit change preserves btc amount`() = test { + widgetsData.value = WidgetsData( + calculatorValues = CalculatorValues( + btcValue = "10000", + fiatValue = "6.25", + satsValue = 10_000L, + displayUnit = BitcoinDisplayUnit.MODERN, + ) + ) + sut = createSut() + advanceUntilIdle() + + currencyState.value = CurrencyState(displayUnit = BitcoinDisplayUnit.CLASSIC) + advanceUntilIdle() + + assertEquals("0.00010000", sut.uiState.value.btcValue) + assertEquals("6.25", sut.uiState.value.fiatValue) + assertEquals(10_000L, lastConvertedSats) + assertEquals("0.00010000", widgetsData.value.calculatorValues.btcValue) + assertEquals(10_000L, widgetsData.value.calculatorValues.satsValue) + assertEquals(BitcoinDisplayUnit.CLASSIC, widgetsData.value.calculatorValues.displayUnit) + } + + @Test + fun `init formats stored sats for current display unit`() = test { + currencyState.value = CurrencyState(displayUnit = BitcoinDisplayUnit.CLASSIC) + widgetsData.value = WidgetsData( + calculatorValues = CalculatorValues( + btcValue = "10000", + fiatValue = "6.25", + satsValue = 10_000L, + displayUnit = BitcoinDisplayUnit.MODERN, + ) + ) + sut = createSut() + advanceUntilIdle() + + assertEquals("0.00010000", sut.uiState.value.btcValue) + assertEquals("6.25", sut.uiState.value.fiatValue) + assertEquals("0.00010000", widgetsData.value.calculatorValues.btcValue) + assertEquals(10_000L, widgetsData.value.calculatorValues.satsValue) + assertEquals(BitcoinDisplayUnit.CLASSIC, widgetsData.value.calculatorValues.displayUnit) + } + + @Test + fun `init backfills legacy integer btc as sats`() = test { + currencyState.value = CurrencyState(displayUnit = BitcoinDisplayUnit.CLASSIC) + widgetsData.value = WidgetsData( + calculatorValues = CalculatorValues( + btcValue = "10000", + fiatValue = "", + ) + ) + sut = createSut() + advanceUntilIdle() + + assertEquals("0.00010000", sut.uiState.value.btcValue) + assertEquals("6.25", sut.uiState.value.fiatValue) + assertEquals(10_000L, widgetsData.value.calculatorValues.satsValue) + assertEquals(BitcoinDisplayUnit.CLASSIC, widgetsData.value.calculatorValues.displayUnit) + } + + @Test + fun `init preserves legacy btc when stored fiat is stale`() = test { + fiatToSatsValue = 5_000uL + fiatConversionValue = "12.50" + widgetsData.value = WidgetsData( + calculatorValues = CalculatorValues( + btcValue = "10000", + fiatValue = "6.25", + ) + ) + sut = createSut() + advanceUntilIdle() + + assertEquals("10000", sut.uiState.value.btcValue) + assertEquals("12.50", sut.uiState.value.fiatValue) + assertEquals(10_000L, widgetsData.value.calculatorValues.satsValue) + assertEquals(BitcoinDisplayUnit.MODERN, widgetsData.value.calculatorValues.displayUnit) + assertEquals("12.50", widgetsData.value.calculatorValues.fiatValue) + } + + @Test + fun `init backfills legacy whole btc from stored fiat`() = test { + currencyState.value = CurrencyState(displayUnit = BitcoinDisplayUnit.CLASSIC) + fiatToSatsValue = 100_000_000uL + fiatConversionValue = "65000.00" + widgetsData.value = WidgetsData( + calculatorValues = CalculatorValues( + btcValue = "1", + fiatValue = "65000.00", + ) + ) + sut = createSut() + advanceUntilIdle() + + assertEquals("1.00000000", sut.uiState.value.btcValue) + assertEquals("65000.00", sut.uiState.value.fiatValue) + assertEquals(100_000_000L, widgetsData.value.calculatorValues.satsValue) + assertEquals(BitcoinDisplayUnit.CLASSIC, widgetsData.value.calculatorValues.displayUnit) + } + + private fun createSut() = CalculatorViewModel( + widgetsRepo = widgetsRepo, + currencyRepo = currencyRepo, + ) + + private fun currentFiatValue() = when (currencyState.value.selectedCurrency) { + "EUR" -> "5.50" + else -> "6.25" + } +} diff --git a/app/src/test/java/to/bitkit/ui/screens/widgets/calculator/components/CalculatorCardStateTest.kt b/app/src/test/java/to/bitkit/ui/screens/widgets/calculator/components/CalculatorCardStateTest.kt index 7574713a6e..caa0712ba6 100644 --- a/app/src/test/java/to/bitkit/ui/screens/widgets/calculator/components/CalculatorCardStateTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/widgets/calculator/components/CalculatorCardStateTest.kt @@ -3,6 +3,24 @@ package to.bitkit.ui.screens.widgets.calculator.components import org.junit.Before import org.junit.Test import to.bitkit.models.BitcoinDisplayUnit +import to.bitkit.models.widget.CalculatorValues +import to.bitkit.models.widget.resolveCalculatorSatsValue +import to.bitkit.ui.components.KEY_000 +import to.bitkit.ui.components.KEY_DECIMAL +import to.bitkit.ui.components.KEY_DELETE +import to.bitkit.ui.screens.widgets.calculator.CALCULATOR_FIAT_DECIMAL_PLACES +import to.bitkit.ui.screens.widgets.calculator.applyNumberPadInput +import to.bitkit.ui.screens.widgets.calculator.calculatorBtcValueToSats +import to.bitkit.ui.screens.widgets.calculator.calculatorDecimalSeparator +import to.bitkit.ui.screens.widgets.calculator.calculatorSatsToBtcValue +import to.bitkit.ui.screens.widgets.calculator.formatBitcoinPlaceholder +import to.bitkit.ui.screens.widgets.calculator.formatBitcoinValue +import to.bitkit.ui.screens.widgets.calculator.formatFiatPlaceholder +import to.bitkit.ui.screens.widgets.calculator.formatFiatValue +import to.bitkit.ui.screens.widgets.calculator.isBtcValueInSatsRange +import to.bitkit.ui.screens.widgets.calculator.sanitizeDecimalInput +import to.bitkit.ui.screens.widgets.calculator.sanitizeIntegerInput +import to.bitkit.ui.screens.widgets.calculator.shouldHydrateFiatFromStoredBtc import java.util.Locale import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -129,4 +147,136 @@ class CalculatorCardStateTest { assertEquals(".5", sanitizeDecimalInput(".5", maxDecimalPlaces = 2)) assertEquals("12.", sanitizeDecimalInput("12.", maxDecimalPlaces = 2)) } + + @Test + fun `formatBitcoinValue groups modern sats without changing raw value`() { + assertEquals("1 345", formatBitcoinValue("1345", BitcoinDisplayUnit.MODERN)) + assertEquals("12 345", formatBitcoinValue("12345", BitcoinDisplayUnit.MODERN)) + assertEquals("1 234 567", formatBitcoinValue("1234567", BitcoinDisplayUnit.MODERN)) + } + + @Test + fun `formatBitcoinValue groups classic bitcoin integer part`() { + assertEquals("1 345.67", formatBitcoinValue("1345.67", BitcoinDisplayUnit.CLASSIC)) + assertEquals("0.00010000", formatBitcoinValue("0.00010000", BitcoinDisplayUnit.CLASSIC)) + } + + @Test + fun `calculatorSatsToBtcValue formats canonical sats for display unit`() { + assertEquals("10000", calculatorSatsToBtcValue(10_000L, BitcoinDisplayUnit.MODERN)) + assertEquals("0.00010000", calculatorSatsToBtcValue(10_000L, BitcoinDisplayUnit.CLASSIC)) + } + + @Test + fun `calculatorBtcValueToSats clamps values above sats range`() { + assertEquals(Long.MAX_VALUE, calculatorBtcValueToSats("999999999999999999999", BitcoinDisplayUnit.MODERN)) + assertEquals(Long.MAX_VALUE, calculatorBtcValueToSats("100000000000", BitcoinDisplayUnit.CLASSIC)) + } + + @Test + fun `isBtcValueInSatsRange rejects values above sats range`() { + assertTrue(isBtcValueInSatsRange("", BitcoinDisplayUnit.MODERN)) + assertTrue(isBtcValueInSatsRange("9223372036854775807", BitcoinDisplayUnit.MODERN)) + assertFalse(isBtcValueInSatsRange("9223372036854775808", BitcoinDisplayUnit.MODERN)) + assertTrue(isBtcValueInSatsRange("92233720368.54775807", BitcoinDisplayUnit.CLASSIC)) + assertFalse(isBtcValueInSatsRange("92233720368.54775808", BitcoinDisplayUnit.CLASSIC)) + } + + @Test + fun `resolveCalculatorSatsValue prefers stored canonical sats`() { + val values = CalculatorValues( + btcValue = "1.00000000", + satsValue = 10_000L, + displayUnit = BitcoinDisplayUnit.CLASSIC, + ) + + assertEquals(10_000L, values.resolveCalculatorSatsValue()) + } + + @Test + fun `resolveCalculatorSatsValue infers legacy modern integer values`() { + val values = CalculatorValues(btcValue = "10000") + + assertEquals(10_000L, values.resolveCalculatorSatsValue()) + } + + @Test + fun `resolveCalculatorSatsValue infers legacy classic decimal values`() { + val values = CalculatorValues(btcValue = "0.00010000") + + assertEquals(10_000L, values.resolveCalculatorSatsValue()) + } + + @Test + fun `formatFiatValue groups fiat integer part`() { + assertEquals("1,345.67", formatFiatValue("1345.67")) + assertEquals("39,621.05", formatFiatValue("39,621.05")) + assertEquals("388,056,887.45", formatFiatValue("388056887.45")) + assertEquals("8.29", formatFiatValue("8.29")) + } + + @Test + fun `formatFiatPlaceholder returns missing decimal zeros`() { + assertEquals("", formatFiatPlaceholder("")) + assertEquals("", formatFiatPlaceholder("1")) + assertEquals("00", formatFiatPlaceholder("1.")) + assertEquals("0", formatFiatPlaceholder("1.2")) + assertEquals("", formatFiatPlaceholder("1.23")) + assertEquals("0", formatFiatPlaceholder("1,2", Locale.GERMANY)) + } + + @Test + fun `formatBitcoinPlaceholder returns missing classic decimal zeros`() { + assertEquals("", formatBitcoinPlaceholder("", BitcoinDisplayUnit.CLASSIC)) + assertEquals("", formatBitcoinPlaceholder("1", BitcoinDisplayUnit.CLASSIC)) + assertEquals("", formatBitcoinPlaceholder("1.2", BitcoinDisplayUnit.MODERN)) + assertEquals("00000000", formatBitcoinPlaceholder("1.", BitcoinDisplayUnit.CLASSIC)) + assertEquals("0000", formatBitcoinPlaceholder("1.2345", BitcoinDisplayUnit.CLASSIC)) + assertEquals("", formatBitcoinPlaceholder("1.23456789", BitcoinDisplayUnit.CLASSIC)) + assertEquals("0000000", formatBitcoinPlaceholder("1,2", BitcoinDisplayUnit.CLASSIC, Locale.GERMANY)) + } + + @Test + fun `formatted values use app money separators`() { + assertEquals(".", calculatorDecimalSeparator()) + assertEquals( + "1 345.67", + formatBitcoinValue("1345.67", BitcoinDisplayUnit.CLASSIC, Locale.GERMANY), + ) + assertEquals("1,345.67", formatFiatValue("1345,67", Locale.GERMANY)) + assertEquals("388,056,887.45", formatFiatValue("388056887,45", Locale.GERMANY)) + } + + @Test + fun `applyNumberPadInput builds integer values`() { + assertEquals("1", applyNumberPadInput("", "1", maxDecimalPlaces = null)) + assertEquals("1000", applyNumberPadInput("1", KEY_000, maxDecimalPlaces = null)) + assertEquals("100", applyNumberPadInput("1000", KEY_DELETE, maxDecimalPlaces = null)) + assertEquals("100", applyNumberPadInput("100", KEY_DECIMAL, maxDecimalPlaces = null)) + } + + @Test + fun `applyNumberPadInput builds decimal values`() { + assertEquals("0.", applyNumberPadInput("", KEY_DECIMAL, maxDecimalPlaces = 2)) + assertEquals("0.5", applyNumberPadInput("0.", "5", maxDecimalPlaces = 2)) + assertEquals("0.56", applyNumberPadInput("0.5", "6", maxDecimalPlaces = 2)) + assertEquals("0.56", applyNumberPadInput("0.56", "7", maxDecimalPlaces = 2)) + assertEquals("0.5", applyNumberPadInput("0.56", KEY_DELETE, maxDecimalPlaces = 2)) + } + + @Test + fun `applyNumberPadInput deletes from grouped fiat values`() { + assertEquals( + "3960.5", + applyNumberPadInput("3,960.50", KEY_DELETE, CALCULATOR_FIAT_DECIMAL_PLACES, Locale.GERMANY), + ) + assertEquals( + "396", + applyNumberPadInput("3,960", KEY_DELETE, CALCULATOR_FIAT_DECIMAL_PLACES, Locale.GERMANY), + ) + assertEquals( + "3.9", + applyNumberPadInput("3,96", KEY_DELETE, CALCULATOR_FIAT_DECIMAL_PLACES, Locale.GERMANY), + ) + } } diff --git a/app/src/test/java/to/bitkit/ui/utils/visualTransformation/BitcoinVisualTransformationTest.kt b/app/src/test/java/to/bitkit/ui/utils/visualTransformation/BitcoinVisualTransformationTest.kt deleted file mode 100644 index e799cf1534..0000000000 --- a/app/src/test/java/to/bitkit/ui/utils/visualTransformation/BitcoinVisualTransformationTest.kt +++ /dev/null @@ -1,64 +0,0 @@ -package to.bitkit.ui.utils.visualTransformation - -import androidx.compose.ui.text.AnnotatedString -import org.junit.Before -import org.junit.Test -import to.bitkit.models.BitcoinDisplayUnit -import java.util.Locale -import kotlin.test.assertEquals - -class BitcoinVisualTransformationTest { - - @Before - fun setLocale() { - Locale.setDefault(Locale.US) - } - - @Test - fun `modern filter strips non-digits from pasted input`() { - val result = BitcoinVisualTransformation(BitcoinDisplayUnit.MODERN) - .filter(AnnotatedString("1000087188..........,,,,,")) - - assertEquals("1 000 087 188", result.text.text) - } - - @Test - fun `classic filter keeps single decimal separator only`() { - val result = BitcoinVisualTransformation(BitcoinDisplayUnit.CLASSIC) - .filter(AnnotatedString("1,23.4.5")) - - assertEquals("123.45", result.text.text) - } - - @Test - fun `modern filter cursor mapping handles leading zeros`() { - val result = BitcoinVisualTransformation(BitcoinDisplayUnit.MODERN) - .filter(AnnotatedString("01000")) - - assertEquals("1 000", result.text.text) - - val mapping = result.offsetMapping - assertEquals(0, mapping.originalToTransformed(0)) - // Raw offset 1 (after the stripped leading '0') should still land at the - // start of "1 000", not past the displayed '1'. - assertEquals(0, mapping.originalToTransformed(1)) - assertEquals(5, mapping.originalToTransformed(5)) - // Transformed offset 1 (after the '1') maps back to raw offset 2. - assertEquals(2, mapping.transformedToOriginal(1)) - } - - @Test - fun `modern filter cursor mapping handles multiple leading zeros`() { - val result = BitcoinVisualTransformation(BitcoinDisplayUnit.MODERN) - .filter(AnnotatedString("00100")) - - assertEquals("100", result.text.text) - - val mapping = result.offsetMapping - assertEquals(0, mapping.originalToTransformed(0)) - assertEquals(0, mapping.originalToTransformed(1)) - assertEquals(0, mapping.originalToTransformed(2)) - assertEquals(1, mapping.originalToTransformed(3)) - assertEquals(3, mapping.originalToTransformed(5)) - } -} diff --git a/changelog.d/next/942.changed.md b/changelog.d/next/942.changed.md new file mode 100644 index 0000000000..d8aef0565e --- /dev/null +++ b/changelog.d/next/942.changed.md @@ -0,0 +1 @@ +Redesigned the Bitcoin Calculator widget to v61 design and replaced the OS keyboard with a dark-themed in-app numpad