diff --git a/app/src/main/java/to/bitkit/models/TransactionSpeed.kt b/app/src/main/java/to/bitkit/models/TransactionSpeed.kt index 8d3cb955c..b26b1826d 100644 --- a/app/src/main/java/to/bitkit/models/TransactionSpeed.kt +++ b/app/src/main/java/to/bitkit/models/TransactionSpeed.kt @@ -2,6 +2,7 @@ package to.bitkit.models import androidx.compose.runtime.Composable import androidx.compose.ui.res.stringResource +import com.synonym.bitkitcore.FeeRates import kotlinx.serialization.KSerializer import kotlinx.serialization.Serializable import kotlinx.serialization.descriptors.PrimitiveKind @@ -25,6 +26,13 @@ sealed class TransactionSpeed { is Custom -> "custom_$satsPerVByte" } + fun getFeeRate(feeRates: FeeRates): UInt = when (this) { + is Fast -> feeRates.fast + is Medium -> feeRates.mid + is Slow -> feeRates.slow + is Custom -> satsPerVByte + } + companion object { fun default(): TransactionSpeed = Medium diff --git a/app/src/main/java/to/bitkit/repositories/SweepRepo.kt b/app/src/main/java/to/bitkit/repositories/SweepRepo.kt new file mode 100644 index 000000000..0f4bb22ee --- /dev/null +++ b/app/src/main/java/to/bitkit/repositories/SweepRepo.kt @@ -0,0 +1,137 @@ +package to.bitkit.repositories + +import com.synonym.bitkitcore.FeeRates +import com.synonym.bitkitcore.broadcastSweepTransaction +import com.synonym.bitkitcore.checkSweepableBalances +import com.synonym.bitkitcore.prepareSweepTransaction +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.withContext +import to.bitkit.async.ServiceQueue +import to.bitkit.data.keychain.Keychain +import to.bitkit.di.BgDispatcher +import to.bitkit.env.Env +import to.bitkit.models.toCoreNetwork +import to.bitkit.services.CoreService +import to.bitkit.utils.Logger +import to.bitkit.utils.ServiceError +import to.bitkit.viewmodels.SweepResult +import to.bitkit.viewmodels.SweepTransactionPreview +import to.bitkit.viewmodels.SweepableBalances +import javax.inject.Inject +import javax.inject.Singleton +import com.synonym.bitkitcore.SweepResult as BitkitCoreSweepResult +import com.synonym.bitkitcore.SweepTransactionPreview as BitkitCoreSweepTransactionPreview +import com.synonym.bitkitcore.SweepableBalances as BitkitCoreSweepableBalances + +@Singleton +class SweepRepo @Inject constructor( + @BgDispatcher private val bgDispatcher: CoroutineDispatcher, + private val keychain: Keychain, + private val coreService: CoreService, +) { + suspend fun checkSweepableBalances(): Result = withContext(bgDispatcher) { + runCatching { + val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name) + ?: throw ServiceError.MnemonicNotFound + val passphrase = keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name) + + Logger.debug("Checking sweepable balances...", context = TAG) + + val balances = ServiceQueue.CORE.background { + checkSweepableBalances( + mnemonicPhrase = mnemonic, + network = Env.network.toCoreNetwork(), + bip39Passphrase = passphrase, + electrumUrl = Env.electrumServerUrl, + ) + } + + balances.toSweepableBalances() + } + } + + suspend fun prepareSweepTransaction( + destinationAddress: String, + feeRateSatsPerVbyte: UInt, + ): Result = withContext(bgDispatcher) { + runCatching { + val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name) + ?: throw ServiceError.MnemonicNotFound + val passphrase = keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name) + + Logger.debug("Preparing sweep transaction...", context = TAG) + + val preview = ServiceQueue.CORE.background { + prepareSweepTransaction( + mnemonicPhrase = mnemonic, + network = Env.network.toCoreNetwork(), + bip39Passphrase = passphrase, + electrumUrl = Env.electrumServerUrl, + destinationAddress = destinationAddress, + feeRateSatsPerVbyte = feeRateSatsPerVbyte, + ) + } + + preview.toSweepTransactionPreview() + } + } + + suspend fun broadcastSweepTransaction(psbt: String): Result = withContext(bgDispatcher) { + runCatching { + val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name) + ?: throw ServiceError.MnemonicNotFound + val passphrase = keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name) + + Logger.debug("Broadcasting sweep transaction...", context = TAG) + + val result = ServiceQueue.CORE.background { + broadcastSweepTransaction( + psbt = psbt, + mnemonicPhrase = mnemonic, + network = Env.network.toCoreNetwork(), + bip39Passphrase = passphrase, + electrumUrl = Env.electrumServerUrl, + ) + } + + result.toSweepResult() + } + } + + suspend fun getFeeRates(): Result = coreService.blocktank.getFees() + + suspend fun hasSweepableFunds(): Result = checkSweepableBalances().map { balances -> + val hasFunds = balances.totalBalance > 0u + if (hasFunds) { + Logger.info("Found ${balances.totalBalance} sats to sweep", context = TAG) + } else { + Logger.debug("No sweepable funds found", context = TAG) + } + hasFunds + } + + companion object { + private const val TAG = "SweepRepo" + } +} + +private fun BitkitCoreSweepableBalances.toSweepableBalances() = SweepableBalances( + legacyBalance = legacyBalance, + legacyUtxosCount = legacyUtxosCount, + p2shBalance = p2shBalance, + p2shUtxosCount = p2shUtxosCount, + taprootBalance = taprootBalance, + taprootUtxosCount = taprootUtxosCount, +) + +private fun BitkitCoreSweepTransactionPreview.toSweepTransactionPreview() = SweepTransactionPreview( + psbt = psbt, + estimatedFee = estimatedFee, + amountAfterFees = amountAfterFees, + estimatedVsize = estimatedVsize, +) + +private fun BitkitCoreSweepResult.toSweepResult() = SweepResult( + txid = txid, + amountSwept = amountSwept, +) diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index 350286ba1..d7f26ff9c 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -134,6 +134,11 @@ import to.bitkit.ui.settings.advanced.AddressViewerScreen import to.bitkit.ui.settings.advanced.CoinSelectPreferenceScreen import to.bitkit.ui.settings.advanced.ElectrumConfigScreen import to.bitkit.ui.settings.advanced.RgsServerScreen +import to.bitkit.ui.settings.advanced.sweep.SweepConfirmScreen +import to.bitkit.ui.settings.advanced.sweep.SweepFeeCustomScreen +import to.bitkit.ui.settings.advanced.sweep.SweepFeeRateScreen +import to.bitkit.ui.settings.advanced.sweep.SweepSettingsScreen +import to.bitkit.ui.settings.advanced.sweep.SweepSuccessScreen import to.bitkit.ui.settings.appStatus.AppStatusScreen import to.bitkit.ui.settings.backgroundPayments.BackgroundPaymentsIntroScreen import to.bitkit.ui.settings.backgroundPayments.BackgroundPaymentsSettings @@ -169,6 +174,7 @@ import to.bitkit.ui.sheets.LnurlAuthSheet import to.bitkit.ui.sheets.PinSheet import to.bitkit.ui.sheets.QuickPayIntroSheet import to.bitkit.ui.sheets.SendSheet +import to.bitkit.ui.sheets.SweepPromptSheet import to.bitkit.ui.sheets.UpdateSheet import to.bitkit.ui.theme.TRANSITION_SHEET_MS import to.bitkit.ui.utils.AutoReadClipboardHandler @@ -185,6 +191,7 @@ import to.bitkit.viewmodels.CurrencyViewModel import to.bitkit.viewmodels.MainScreenEffect import to.bitkit.viewmodels.RestoreState import to.bitkit.viewmodels.SettingsViewModel +import to.bitkit.viewmodels.SweepViewModel import to.bitkit.viewmodels.TransferViewModel import to.bitkit.viewmodels.WalletViewModel @@ -333,7 +340,10 @@ fun ContentView( return } else if (restoreState is RestoreState.Completed) { WalletRestoreSuccessView( - onContinue = { walletViewModel.onRestoreContinue() }, + onContinue = { + walletViewModel.onRestoreContinue() + appViewModel.checkForSweepableFunds() + }, ) return } @@ -397,6 +407,13 @@ fun ContentView( is Sheet.Backup -> BackupSheet(sheet, onDismiss = { appViewModel.hideSheet() }) is Sheet.LnurlAuth -> LnurlAuthSheet(sheet, appViewModel) Sheet.ForceTransfer -> ForceTransferSheet(appViewModel, transferViewModel) + Sheet.SweepPrompt -> SweepPromptSheet( + onSweep = { + appViewModel.hideSheet() + navController.navigate(Routes.SweepNav) + }, + onCancel = { appViewModel.hideSheet() }, + ) is Sheet.Gift -> GiftSheet(sheet, appViewModel) is Sheet.TimedSheet -> { when (sheet.type) { @@ -996,6 +1013,34 @@ private fun NavGraphBuilder.advancedSettings(navController: NavHostController) { composableWithDefaultTransitions { AddressViewerScreen(navController) } + navigationWithDefaultTransitions( + startDestination = Routes.Sweep, + ) { + composableWithDefaultTransitions { + val parentEntry = remember(it) { navController.getBackStackEntry(Routes.SweepNav) } + val viewModel = hiltViewModel(parentEntry) + SweepSettingsScreen(navController, viewModel) + } + composableWithDefaultTransitions { + val parentEntry = remember(it) { navController.getBackStackEntry(Routes.SweepNav) } + val viewModel = hiltViewModel(parentEntry) + SweepConfirmScreen(navController, viewModel) + } + composableWithDefaultTransitions { + val parentEntry = remember(it) { navController.getBackStackEntry(Routes.SweepNav) } + val viewModel = hiltViewModel(parentEntry) + SweepFeeRateScreen(navController, viewModel) + } + composableWithDefaultTransitions { + val parentEntry = remember(it) { navController.getBackStackEntry(Routes.SweepNav) } + val viewModel = hiltViewModel(parentEntry) + SweepFeeCustomScreen(navController, viewModel) + } + composableWithDefaultTransitions { + val route = it.toRoute() + SweepSuccessScreen(navController, amountSats = route.amountSats) + } + } composableWithDefaultTransitions { NodeInfoScreen(navController) } @@ -1689,6 +1734,24 @@ sealed interface Routes { @Serializable data object AddressViewer : Routes + @Serializable + data object SweepNav : Routes + + @Serializable + data object Sweep : Routes + + @Serializable + data object SweepConfirm : Routes + + @Serializable + data object SweepFeeRate : Routes + + @Serializable + data object SweepFeeCustom : Routes + + @Serializable + data class SweepSuccess(val amountSats: Long) : Routes + @Serializable data object AboutSettings : Routes diff --git a/app/src/main/java/to/bitkit/ui/components/SheetHost.kt b/app/src/main/java/to/bitkit/ui/components/SheetHost.kt index f7700fc5e..599569b21 100644 --- a/app/src/main/java/to/bitkit/ui/components/SheetHost.kt +++ b/app/src/main/java/to/bitkit/ui/components/SheetHost.kt @@ -45,6 +45,7 @@ sealed interface Sheet { data class LnurlAuth(val domain: String, val lnurl: String, val k1: String) : Sheet data object ForceTransfer : Sheet data class Gift(val code: String, val amount: ULong) : Sheet + data object SweepPrompt : Sheet data class TimedSheet(val type: TimedSheetType) : Sheet } diff --git a/app/src/main/java/to/bitkit/ui/onboarding/InitializingWalletView.kt b/app/src/main/java/to/bitkit/ui/onboarding/InitializingWalletView.kt index 02ac78e4a..806c5ff6b 100644 --- a/app/src/main/java/to/bitkit/ui/onboarding/InitializingWalletView.kt +++ b/app/src/main/java/to/bitkit/ui/onboarding/InitializingWalletView.kt @@ -26,6 +26,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.rotate +import androidx.compose.ui.keepScreenOn import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -54,7 +55,9 @@ fun InitializingWalletView( isRestoring: Boolean = false, ) { BoxWithConstraints( - modifier = Modifier.fillMaxSize(), + modifier = Modifier + .fillMaxSize() + .keepScreenOn(), contentAlignment = Alignment.Center, ) { val percentage = remember { Animatable(0f) } diff --git a/app/src/main/java/to/bitkit/ui/screens/MigrationLoadingScreen.kt b/app/src/main/java/to/bitkit/ui/screens/MigrationLoadingScreen.kt index 8232df731..a27765cbd 100644 --- a/app/src/main/java/to/bitkit/ui/screens/MigrationLoadingScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/MigrationLoadingScreen.kt @@ -3,27 +3,30 @@ package to.bitkit.ui.screens import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.material3.CircularProgressIndicator import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.keepScreenOn +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp +import to.bitkit.R import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.Display +import to.bitkit.ui.components.VerticalSpacer +import to.bitkit.ui.scaffold.AppTopBar +import to.bitkit.ui.shared.util.screen import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors +import to.bitkit.ui.utils.withAccent @Composable fun MigrationLoadingScreen(isVisible: Boolean = true) { @@ -31,47 +34,64 @@ fun MigrationLoadingScreen(isVisible: Boolean = true) { visible = isVisible, enter = fadeIn(), exit = fadeOut(), + modifier = Modifier.keepScreenOn(), ) { - Box( - contentAlignment = Alignment.Center, + Content() + } +} + +@Composable +private fun Content() { + Column( + modifier = Modifier + .screen() + .padding(horizontal = 32.dp) + ) { + AppTopBar( + titleText = stringResource(R.string.migration__nav_title), + onBackClick = null, + ) + + Image( + painter = painterResource(R.drawable.wallet), + contentDescription = null, modifier = Modifier - .fillMaxSize() - .background(Color.Black) - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.padding(24.dp) - ) { - CircularProgressIndicator( - modifier = Modifier.padding(16.dp), - color = Color.White, - ) + .fillMaxWidth() + .aspectRatio(1.0f) + .weight(1f) + ) - Spacer(modifier = Modifier.height(24.dp)) + Display( + text = stringResource(R.string.migration__title).withAccent(accentColor = Colors.Brand), + color = Colors.White, + ) - Display( - text = "Updating Wallet", - fontWeight = FontWeight.SemiBold, - fontSize = 24.sp, - color = Color.White, - ) + BodyM( + text = stringResource(R.string.migration__description), + color = Colors.White64, + ) - Spacer(modifier = Modifier.height(8.dp)) + VerticalSpacer(32.dp) - BodyM( - text = "Please wait while we update the app...", - color = Colors.White64, - textAlign = TextAlign.Center, - ) - } + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxWidth() + ) { + CircularProgressIndicator( + modifier = Modifier.size(32.dp), + color = Colors.White32, + strokeWidth = 3.dp, + ) } + + VerticalSpacer(32.dp) } } -@Preview +@Preview(showSystemUi = true) @Composable -fun MigrationLoadingScreenPreview() { +private fun Preview() { AppThemeSurface { - MigrationLoadingScreen() + Content() } } diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/SavingsProgressScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/SavingsProgressScreen.kt index 233de53eb..56fcf5868 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/SavingsProgressScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/SavingsProgressScreen.kt @@ -1,7 +1,5 @@ package to.bitkit.ui.screens.transfer -import android.view.WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON -import androidx.activity.compose.LocalActivity import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -11,7 +9,6 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -19,6 +16,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.keepScreenOn import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag @@ -56,7 +54,6 @@ fun SavingsProgressScreen( onContinueClick: () -> Unit = {}, onTransferUnavailable: () -> Unit = {}, ) { - val window = LocalActivity.current?.window val context = LocalContext.current var progressState by remember { mutableStateOf(SavingsProgressState.PROGRESS) } @@ -66,8 +63,6 @@ fun SavingsProgressScreen( val channelsFailedToCoopClose = transfer.closeSelectedChannels() if (channelsFailedToCoopClose.isEmpty()) { - window?.clearFlags(FLAG_KEEP_SCREEN_ON) - wallet.refreshState() delay(5000) progressState = SavingsProgressState.SUCCESS @@ -77,7 +72,6 @@ fun SavingsProgressScreen( if (nonTrustedChannels.isEmpty()) { // All channels are trusted peers - show error and navigate back immediately - window?.clearFlags(FLAG_KEEP_SCREEN_ON) app.toast( type = Toast.ToastType.ERROR, title = context.getString(R.string.lightning__close_error), @@ -89,7 +83,6 @@ fun SavingsProgressScreen( channels = nonTrustedChannels, onGiveUp = { app.showSheet(Sheet.ForceTransfer) }, onTransferUnavailable = { - window?.clearFlags(FLAG_KEEP_SCREEN_ON) app.toast( type = Toast.ToastType.ERROR, title = context.getString(R.string.lightning__close_error), @@ -104,17 +97,10 @@ fun SavingsProgressScreen( } } - // Keeps screen on while this view is active - DisposableEffect(Unit) { - window?.addFlags(FLAG_KEEP_SCREEN_ON) - onDispose { - window?.clearFlags(FLAG_KEEP_SCREEN_ON) - } - } - Content( progressState = progressState, onContinueClick = { onContinueClick() }, + modifier = Modifier.keepScreenOn(), ) } @@ -122,10 +108,11 @@ fun SavingsProgressScreen( private fun Content( progressState: SavingsProgressState, onContinueClick: () -> Unit = {}, + modifier: Modifier = Modifier, ) { val inProgress = progressState == SavingsProgressState.PROGRESS ScreenColumn( - modifier = Modifier.testTag(if (inProgress) "TransferSettingUp" else "TransferSuccess") + modifier = modifier.testTag(if (inProgress) "TransferSettingUp" else "TransferSuccess") ) { AppTopBar( titleText = when (progressState) { diff --git a/app/src/main/java/to/bitkit/ui/settings/AdvancedSettingsScreen.kt b/app/src/main/java/to/bitkit/ui/settings/AdvancedSettingsScreen.kt index 1bd2beb90..6e4467bb3 100644 --- a/app/src/main/java/to/bitkit/ui/settings/AdvancedSettingsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/AdvancedSettingsScreen.kt @@ -57,6 +57,9 @@ fun AdvancedSettingsScreen( onAddressViewerClick = { navController.navigate(Routes.AddressViewer) }, + onSweepFundsClick = { + navController.navigate(Routes.SweepNav) + }, onSuggestionsResetClick = { showResetSuggestionsDialog = true }, onResetSuggestionsDialogConfirm = { viewModel.resetSuggestions() @@ -77,6 +80,7 @@ private fun Content( onElectrumServerClick: () -> Unit = {}, onRgsServerClick: () -> Unit = {}, onAddressViewerClick: () -> Unit = {}, + onSweepFundsClick: () -> Unit = {}, onSuggestionsResetClick: () -> Unit = {}, onResetSuggestionsDialogConfirm: () -> Unit = {}, onResetSuggestionsDialogCancel: () -> Unit = {}, @@ -139,6 +143,12 @@ private fun Content( modifier = Modifier.testTag("AddressViewer"), ) + SettingsButtonRow( + title = stringResource(R.string.sweep__nav_title), + onClick = onSweepFundsClick, + modifier = Modifier.testTag("SweepFunds"), + ) + SettingsButtonRow( title = stringResource(R.string.settings__adv__suggestions_reset), onClick = onSuggestionsResetClick, diff --git a/app/src/main/java/to/bitkit/ui/settings/advanced/sweep/SweepConfirmScreen.kt b/app/src/main/java/to/bitkit/ui/settings/advanced/sweep/SweepConfirmScreen.kt new file mode 100644 index 000000000..7b497d58f --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/settings/advanced/sweep/SweepConfirmScreen.kt @@ -0,0 +1,300 @@ +package to.bitkit.ui.settings.advanced.sweep + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavController +import kotlinx.coroutines.launch +import to.bitkit.R +import to.bitkit.models.FeeRate +import to.bitkit.models.TransactionSpeed +import to.bitkit.models.formatToModernDisplay +import to.bitkit.ui.Routes +import to.bitkit.ui.components.BalanceHeaderView +import to.bitkit.ui.components.BodyM +import to.bitkit.ui.components.BodySSB +import to.bitkit.ui.components.Caption +import to.bitkit.ui.components.FillHeight +import to.bitkit.ui.components.SwipeToConfirm +import to.bitkit.ui.components.VerticalSpacer +import to.bitkit.ui.scaffold.AppTopBar +import to.bitkit.ui.scaffold.DrawerNavIcon +import to.bitkit.ui.scaffold.ScreenColumn +import to.bitkit.ui.shared.modifiers.clickableAlpha +import to.bitkit.ui.theme.AppThemeSurface +import to.bitkit.ui.theme.Colors +import to.bitkit.viewmodels.SweepState +import to.bitkit.viewmodels.SweepUiState +import to.bitkit.viewmodels.SweepViewModel + +@Composable +fun SweepConfirmScreen( + navController: NavController, + viewModel: SweepViewModel, +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val scope = rememberCoroutineScope() + + LaunchedEffect(Unit) { + viewModel.loadFeeEstimates() + } + + LaunchedEffect(uiState.selectedFeeRate) { + if (uiState.selectedFeeRate != null && uiState.sweepState != SweepState.Broadcasting) { + viewModel.prepareSweep() + } + } + + LaunchedEffect(uiState.sweepState) { + if (uiState.sweepState is SweepState.Success) { + val amountSats = uiState.sweepResult?.amountSwept?.toLong() ?: 0L + navController.navigate(Routes.SweepSuccess(amountSats = amountSats)) { + popUpTo(Routes.Sweep) { inclusive = true } + } + } + } + + Content( + uiState = uiState, + onBack = { navController.popBackStack() }, + onSelectFeeRate = { navController.navigate(Routes.SweepFeeRate) }, + onSwipeComplete = { + scope.launch { + viewModel.broadcastSweep() + } + }, + ) +} + +@Composable +private fun Content( + uiState: SweepUiState, + onBack: () -> Unit = {}, + onSelectFeeRate: () -> Unit = {}, + onSwipeComplete: () -> Unit = {}, +) { + val isPreparing = uiState.sweepState == SweepState.Preparing + val isReady = uiState.sweepState == SweepState.Ready + + val displayAmount = if (isReady && uiState.transactionPreview != null) { + uiState.transactionPreview.amountAfterFees.toLong() + } else { + (uiState.sweepableBalances?.totalBalance ?: 0u).toLong() + } + + ScreenColumn { + AppTopBar( + titleText = stringResource(R.string.sweep__confirm_nav_title), + onBackClick = onBack, + actions = { DrawerNavIcon() }, + ) + + VerticalSpacer(16.dp) + + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp) + ) { + BalanceHeaderView( + sats = displayAmount, + modifier = Modifier.alpha(if (isPreparing) 0.5f else 1f) + ) + + VerticalSpacer(24.dp) + + HorizontalDivider(color = Colors.White08) + + VerticalSpacer(24.dp) + + Caption( + text = stringResource(R.string.sweep__confirm_to_address), + color = Colors.White64, + ) + + VerticalSpacer(8.dp) + + BodySSB( + text = uiState.destinationAddress?.ifEmpty { "..." } ?: "...", + modifier = Modifier.alpha(if (uiState.destinationAddress == null) 0.5f else 1f) + ) + + VerticalSpacer(24.dp) + + HorizontalDivider(color = Colors.White08) + + VerticalSpacer(24.dp) + + val feeRate = FeeRate.fromSpeed(uiState.selectedSpeed) + val isLoading = isPreparing || uiState.sweepState == SweepState.Broadcasting + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier + .fillMaxWidth() + .then( + if (!isLoading) { + Modifier.clickableAlpha(onClick = onSelectFeeRate) + } else { + Modifier + } + ) + ) { + Column { + Caption( + text = stringResource(R.string.wallet__send_fee_and_speed), + color = Colors.White64, + ) + VerticalSpacer(8.dp) + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + painter = painterResource(feeRate.icon), + contentDescription = null, + tint = feeRate.color, + modifier = Modifier.size(16.dp) + ) + BodySSB( + text = if (uiState.estimatedFee > 0u && !isPreparing) { + " ${stringResource(feeRate.title)} (${ + stringResource( + R.string.sweep__balance_format, + uiState.estimatedFee.toLong().formatToModernDisplay(), + ) + })" + } else { + " ${stringResource(feeRate.title)}" + }, + ) + } + } + + Column(horizontalAlignment = Alignment.End) { + Caption( + text = stringResource(R.string.wallet__send_confirming_in), + color = Colors.White64, + ) + VerticalSpacer(8.dp) + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + painter = painterResource(R.drawable.ic_clock), + contentDescription = null, + tint = Colors.Brand, + modifier = Modifier.size(16.dp) + ) + BodySSB(text = " ${stringResource(feeRate.description)}") + } + } + } + + VerticalSpacer(24.dp) + + HorizontalDivider(color = Colors.White08) + + if (uiState.errorMessage != null) { + VerticalSpacer(16.dp) + BodyM( + text = uiState.errorMessage, + color = Colors.Red, + ) + } + + FillHeight() + + BottomActions( + uiState = uiState, + onSwipeComplete = onSwipeComplete, + ) + + VerticalSpacer(16.dp) + } + } +} + +@Composable +private fun BottomActions( + uiState: SweepUiState, + onSwipeComplete: () -> Unit, +) { + when (uiState.sweepState) { + SweepState.Idle, SweepState.Ready -> { + if (uiState.destinationAddress != null && uiState.transactionPreview != null) { + SwipeToConfirm( + text = stringResource(R.string.sweep__confirm_swipe), + onConfirm = onSwipeComplete, + modifier = Modifier.fillMaxWidth() + ) + } + } + SweepState.Preparing -> LoadingIndicator(stringResource(R.string.sweep__confirm_preparing)) + SweepState.Broadcasting -> LoadingIndicator(stringResource(R.string.sweep__confirm_broadcasting)) + is SweepState.Error -> { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxWidth() + ) { + BodyM( + text = uiState.sweepState.message, + color = Colors.Red, + ) + VerticalSpacer(16.dp) + SwipeToConfirm( + text = stringResource(R.string.sweep__confirm_retry), + onConfirm = onSwipeComplete, + modifier = Modifier.fillMaxWidth() + ) + } + } + is SweepState.Success -> Unit + } +} + +@Composable +private fun LoadingIndicator(text: String) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxWidth() + ) { + CircularProgressIndicator( + modifier = Modifier.size(32.dp), + color = Colors.White32, + strokeWidth = 3.dp, + ) + VerticalSpacer(32.dp) + Caption( + text = text, + color = Colors.White64, + ) + } +} + +@Preview +@Composable +private fun Preview() { + AppThemeSurface { + Content( + uiState = SweepUiState( + destinationAddress = "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4", + selectedSpeed = TransactionSpeed.Medium, + ), + ) + } +} diff --git a/app/src/main/java/to/bitkit/ui/settings/advanced/sweep/SweepFeeCustomScreen.kt b/app/src/main/java/to/bitkit/ui/settings/advanced/sweep/SweepFeeCustomScreen.kt new file mode 100644 index 000000000..059596738 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/settings/advanced/sweep/SweepFeeCustomScreen.kt @@ -0,0 +1,145 @@ +package to.bitkit.ui.settings.advanced.sweep + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavController +import to.bitkit.R +import to.bitkit.ext.toLongOrDefault +import to.bitkit.models.BITCOIN_SYMBOL +import to.bitkit.models.TransactionSpeed +import to.bitkit.models.formatToModernDisplay +import to.bitkit.ui.components.BodyM +import to.bitkit.ui.components.FillHeight +import to.bitkit.ui.components.KEY_DELETE +import to.bitkit.ui.components.LargeRow +import to.bitkit.ui.components.NumberPad +import to.bitkit.ui.components.NumberPadType +import to.bitkit.ui.components.PrimaryButton +import to.bitkit.ui.components.VerticalSpacer +import to.bitkit.ui.components.settings.SectionHeader +import to.bitkit.ui.scaffold.AppTopBar +import to.bitkit.ui.scaffold.DrawerNavIcon +import to.bitkit.ui.scaffold.ScreenColumn +import to.bitkit.ui.theme.AppThemeSurface +import to.bitkit.ui.theme.Colors +import to.bitkit.viewmodels.SweepViewModel + +@Composable +fun SweepFeeCustomScreen( + navController: NavController, + viewModel: SweepViewModel, +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + var input by remember { mutableStateOf("") } + + val estimatedVsize = uiState.transactionPreview?.estimatedVsize ?: 0u + val feeRate = input.toLongOrDefault(0) + val totalFee = feeRate * estimatedVsize.toLong() + val totalFeeText = if (totalFee > 0) { + stringResource( + R.string.sweep__balance_format, + totalFee.formatToModernDisplay(), + ) + " " + stringResource(R.string.sweep__custom_fee_total) + } else { + "" + } + + Content( + input = input, + totalFeeText = totalFeeText, + onKeyPress = { key -> + when (key) { + KEY_DELETE -> input = input.dropLast(1) + else -> { + if (input.length < 6) { + input += key + } + } + } + }, + onBack = { navController.popBackStack() }, + onContinue = { + val rate = input.toUIntOrNull() ?: 1u + viewModel.setFeeRate(TransactionSpeed.Custom(rate)) + navController.popBackStack() + }, + ) +} + +@Composable +private fun Content( + input: String, + totalFeeText: String, + onKeyPress: (String) -> Unit = {}, + onBack: () -> Unit = {}, + onContinue: () -> Unit = {}, +) { + val isValid = input.toLongOrDefault(0) >= 1L + + ScreenColumn { + AppTopBar( + titleText = stringResource(R.string.sweep__custom_fee_nav_title), + onBackClick = onBack, + actions = { DrawerNavIcon() }, + ) + + Column( + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxSize() + ) { + SectionHeader(title = stringResource(R.string.common__sat_vbyte)) + + LargeRow( + prefix = null, + text = input.ifEmpty { "0" }, + symbol = BITCOIN_SYMBOL, + showSymbol = true, + ) + + if (isValid && totalFeeText.isNotEmpty()) { + VerticalSpacer(28.dp) + BodyM(totalFeeText, color = Colors.White64) + } + + FillHeight() + + NumberPad( + onPress = onKeyPress, + type = NumberPadType.SIMPLE, + modifier = Modifier.height(350.dp) + ) + + PrimaryButton( + text = stringResource(R.string.common__continue), + onClick = onContinue, + enabled = isValid, + ) + + VerticalSpacer(16.dp) + } + } +} + +@Preview +@Composable +private fun Preview() { + AppThemeSurface { + Content( + input = "5", + totalFeeText = "₿ 256 for this transaction", + ) + } +} diff --git a/app/src/main/java/to/bitkit/ui/settings/advanced/sweep/SweepFeeRateScreen.kt b/app/src/main/java/to/bitkit/ui/settings/advanced/sweep/SweepFeeRateScreen.kt new file mode 100644 index 000000000..06a39cae7 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/settings/advanced/sweep/SweepFeeRateScreen.kt @@ -0,0 +1,228 @@ +package to.bitkit.ui.settings.advanced.sweep + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavController +import to.bitkit.R +import to.bitkit.env.TransactionDefaults +import to.bitkit.models.FeeRate +import to.bitkit.models.PrimaryDisplay +import to.bitkit.models.TransactionSpeed +import to.bitkit.ui.LocalCurrencies +import to.bitkit.ui.Routes +import to.bitkit.ui.components.BodyMSB +import to.bitkit.ui.components.BodySSB +import to.bitkit.ui.components.FillHeight +import to.bitkit.ui.components.FillWidth +import to.bitkit.ui.components.HorizontalSpacer +import to.bitkit.ui.components.MoneyMSB +import to.bitkit.ui.components.MoneySSB +import to.bitkit.ui.components.PrimaryButton +import to.bitkit.ui.components.VerticalSpacer +import to.bitkit.ui.components.settings.SectionHeader +import to.bitkit.ui.scaffold.AppTopBar +import to.bitkit.ui.scaffold.DrawerNavIcon +import to.bitkit.ui.scaffold.ScreenColumn +import to.bitkit.ui.shared.modifiers.clickableAlpha +import to.bitkit.ui.theme.AppThemeSurface +import to.bitkit.ui.theme.Colors +import to.bitkit.viewmodels.SweepUiState +import to.bitkit.viewmodels.SweepViewModel + +@Composable +fun SweepFeeRateScreen( + navController: NavController, + viewModel: SweepViewModel, +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + Content( + uiState = uiState, + onBack = { navController.popBackStack() }, + onSelectSpeed = { speed -> + viewModel.setFeeRate(speed) + navController.popBackStack() + }, + onCustom = { navController.navigate(Routes.SweepFeeCustom) }, + ) +} + +@Composable +private fun Content( + uiState: SweepUiState, + onBack: () -> Unit = {}, + onSelectSpeed: (TransactionSpeed) -> Unit = {}, + onCustom: () -> Unit = {}, +) { + val feeRates = uiState.feeRates + val estimatedVsize = uiState.transactionPreview?.estimatedVsize ?: 0u + val totalBalance = uiState.sweepableBalances?.totalBalance ?: 0u + + fun getFee(speed: TransactionSpeed): Long { + val feeRate: UInt = when (speed) { + is TransactionSpeed.Custom -> speed.satsPerVByte + else -> feeRates?.let { speed.getFeeRate(it) } ?: 0u + } + return (feeRate.toULong() * estimatedVsize).toLong() + } + + fun isDisabled(speed: TransactionSpeed): Boolean { + val fee = getFee(speed).toULong() + return fee + TransactionDefaults.dustLimit > totalBalance + } + + ScreenColumn { + AppTopBar( + titleText = stringResource(R.string.sweep__fee_nav_title), + onBackClick = onBack, + actions = { DrawerNavIcon() }, + ) + + Column(modifier = Modifier.fillMaxSize()) { + SectionHeader( + title = stringResource(R.string.wallet__send_fee_and_speed), + modifier = Modifier.padding(horizontal = 16.dp) + ) + + if (feeRates == null) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxSize() + ) { + CircularProgressIndicator( + strokeWidth = 2.dp, + color = Colors.White32, + ) + } + return@ScreenColumn + } + + FeeItem( + feeRate = FeeRate.FAST, + sats = getFee(TransactionSpeed.Fast), + isSelected = uiState.selectedSpeed is TransactionSpeed.Fast, + isDisabled = isDisabled(TransactionSpeed.Fast), + onClick = { onSelectSpeed(TransactionSpeed.Fast) }, + ) + + FeeItem( + feeRate = FeeRate.NORMAL, + sats = getFee(TransactionSpeed.Medium), + isSelected = uiState.selectedSpeed is TransactionSpeed.Medium, + isDisabled = isDisabled(TransactionSpeed.Medium), + onClick = { onSelectSpeed(TransactionSpeed.Medium) }, + ) + + FeeItem( + feeRate = FeeRate.SLOW, + sats = getFee(TransactionSpeed.Slow), + isSelected = uiState.selectedSpeed is TransactionSpeed.Slow, + isDisabled = isDisabled(TransactionSpeed.Slow), + onClick = { onSelectSpeed(TransactionSpeed.Slow) }, + ) + + val customRate = (uiState.selectedSpeed as? TransactionSpeed.Custom)?.satsPerVByte ?: 0u + FeeItem( + feeRate = FeeRate.CUSTOM, + sats = if (customRate > 0u) getFee(TransactionSpeed.Custom(customRate)) else 0L, + isSelected = uiState.selectedSpeed is TransactionSpeed.Custom, + isDisabled = false, + onClick = onCustom, + ) + + FillHeight(min = 16.dp) + + PrimaryButton( + text = stringResource(R.string.common__continue), + onClick = onBack, + modifier = Modifier.padding(horizontal = 16.dp) + ) + + VerticalSpacer(16.dp) + } + } +} + +@Composable +private fun FeeItem( + feeRate: FeeRate, + sats: Long, + onClick: () -> Unit, + modifier: Modifier = Modifier, + isSelected: Boolean = false, + isDisabled: Boolean = false, + unit: PrimaryDisplay = LocalCurrencies.current.primaryDisplay, +) { + val color = if (isDisabled) Colors.Gray3 else MaterialTheme.colorScheme.primary + val accent = if (isDisabled) Colors.Gray3 else MaterialTheme.colorScheme.secondary + Column( + modifier = modifier + .clickableAlpha(onClick = onClick) + .then( + if (isSelected) Modifier.background(Colors.White06) else Modifier + ), + ) { + HorizontalDivider(Modifier.padding(horizontal = 16.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .padding(horizontal = 16.dp) + .height(90.dp) + ) { + Icon( + painter = painterResource(feeRate.icon), + contentDescription = null, + tint = when { + isDisabled -> Colors.Gray3 + else -> feeRate.color + }, + modifier = Modifier.size(32.dp), + ) + HorizontalSpacer(16.dp) + Column { + BodyMSB(stringResource(feeRate.title), color = color) + BodySSB(stringResource(feeRate.description), color = accent) + } + FillWidth() + if (sats != 0L) { + Column( + horizontalAlignment = Alignment.End, + ) { + MoneyMSB(sats, color = color, accent = accent) + MoneySSB(sats, unit = unit.not(), color = accent, accent = accent, showSymbol = true) + } + } + } + } +} + +@Preview(showSystemUi = true) +@Composable +private fun Preview() { + AppThemeSurface { + Content( + uiState = SweepUiState( + selectedSpeed = TransactionSpeed.Medium, + ), + ) + } +} diff --git a/app/src/main/java/to/bitkit/ui/settings/advanced/sweep/SweepSettingsScreen.kt b/app/src/main/java/to/bitkit/ui/settings/advanced/sweep/SweepSettingsScreen.kt new file mode 100644 index 000000000..a3f8b98ce --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/settings/advanced/sweep/SweepSettingsScreen.kt @@ -0,0 +1,377 @@ +package to.bitkit.ui.settings.advanced.sweep + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavController +import to.bitkit.R +import to.bitkit.ui.Routes +import to.bitkit.ui.components.BodyM +import to.bitkit.ui.components.BodySSB +import to.bitkit.ui.components.Caption +import to.bitkit.ui.components.MoneySSB +import to.bitkit.ui.components.PrimaryButton +import to.bitkit.ui.components.Title +import to.bitkit.ui.components.VerticalSpacer +import to.bitkit.ui.components.rememberMoneyText +import to.bitkit.ui.scaffold.AppTopBar +import to.bitkit.ui.scaffold.DrawerNavIcon +import to.bitkit.ui.scaffold.ScreenColumn +import to.bitkit.ui.theme.AppTextStyles +import to.bitkit.ui.theme.AppThemeSurface +import to.bitkit.ui.theme.Colors +import to.bitkit.ui.utils.withAccent +import to.bitkit.viewmodels.CheckState +import to.bitkit.viewmodels.SweepUiState +import to.bitkit.viewmodels.SweepViewModel +import to.bitkit.viewmodels.SweepableBalances + +@Composable +fun SweepSettingsScreen( + navController: NavController, + viewModel: SweepViewModel, +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + LaunchedEffect(Unit) { + viewModel.reset() + viewModel.checkBalance() + } + + Content( + uiState = uiState, + onBack = { navController.popBackStack() }, + onSweepToWallet = { navController.navigate(Routes.SweepConfirm) }, + onRetry = { viewModel.checkBalance() }, + ) +} + +@Composable +private fun Content( + uiState: SweepUiState, + onBack: () -> Unit = {}, + onSweepToWallet: () -> Unit = {}, + onRetry: () -> Unit = {}, +) { + val title = when (uiState.checkState) { + is CheckState.Found -> stringResource(R.string.sweep__found_title) + is CheckState.NoFunds -> stringResource(R.string.sweep__no_funds_title) + else -> stringResource(R.string.sweep__nav_title) + } + + ScreenColumn { + AppTopBar( + titleText = title, + onBackClick = onBack, + actions = { DrawerNavIcon() }, + ) + + VerticalSpacer(30.dp) + + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp) + ) { + when (uiState.checkState) { + CheckState.Idle, CheckState.Checking -> LoadingView() + is CheckState.Found -> FoundFundsView( + balances = uiState.sweepableBalances ?: SweepableBalances(), + onSweepToWallet = onSweepToWallet, + ) + CheckState.NoFunds -> NoFundsView(onBack = onBack) + is CheckState.Error -> ErrorView( + message = uiState.checkState.message, + onRetry = onRetry, + ) + } + } + } +} + +@Composable +private fun LoadingView() { + Column( + modifier = Modifier.fillMaxSize() + ) { + BodyM( + text = stringResource(R.string.sweep__checking_description), + color = Colors.White64, + ) + + Spacer(modifier = Modifier.weight(1f)) + + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + ) { + Image( + painter = painterResource(id = R.drawable.magnifying_glass), + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = Modifier.size(311.dp) + ) + } + + Spacer(modifier = Modifier.weight(1f)) + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxWidth() + ) { + CircularProgressIndicator( + modifier = Modifier.size(32.dp), + color = Colors.White32, + strokeWidth = 3.dp, + ) + + VerticalSpacer(16.dp) + + Caption( + text = stringResource(R.string.sweep__checking_loading), + color = Colors.White64, + ) + } + + VerticalSpacer(32.dp) + } +} + +@Composable +private fun FoundFundsView( + balances: SweepableBalances, + onSweepToWallet: () -> Unit, +) { + Column( + modifier = Modifier.fillMaxSize() + ) { + BodyM( + text = stringResource(R.string.sweep__found_description), + color = Colors.White64, + ) + + VerticalSpacer(24.dp) + + Caption( + text = stringResource(R.string.sweep__found_label), + color = Colors.White64, + ) + + VerticalSpacer(16.dp) + + if (balances.legacyBalance > 0u) { + FundRow( + title = stringResource(R.string.sweep__legacy_title), + utxoCount = balances.legacyUtxosCount, + balance = balances.legacyBalance, + ) + } + + if (balances.p2shBalance > 0u) { + FundRow( + title = stringResource(R.string.sweep__segwit_title), + utxoCount = balances.p2shUtxosCount, + balance = balances.p2shBalance, + ) + } + + if (balances.taprootBalance > 0u) { + FundRow( + title = stringResource(R.string.sweep__taproot_title), + utxoCount = balances.taprootUtxosCount, + balance = balances.taprootBalance, + ) + } + + VerticalSpacer(16.dp) + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + Title(text = stringResource(R.string.sweep__total)) + rememberMoneyText(sats = balances.totalBalance.toLong(), showSymbol = true)?.let { + Text(text = it.withAccent(accentColor = Colors.White), style = AppTextStyles.Title) + } + } + + Spacer(modifier = Modifier.weight(1f)) + + PrimaryButton( + text = stringResource(R.string.sweep__to_wallet), + onClick = onSweepToWallet, + modifier = Modifier.fillMaxWidth() + ) + + VerticalSpacer(16.dp) + } +} + +@Composable +private fun FundRow( + title: String, + utxoCount: UInt, + balance: ULong, +) { + val utxoLabel = if (utxoCount == 1u) { + stringResource(R.string.sweep__utxo_format, title, utxoCount.toInt()) + } else { + stringResource(R.string.sweep__utxos_format, title, utxoCount.toInt()) + } + Column { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp) + ) { + BodySSB(text = utxoLabel) + MoneySSB(sats = balance.toLong(), showSymbol = true) + } + HorizontalDivider(color = Colors.White08) + } +} + +@Composable +private fun NoFundsView(onBack: () -> Unit) { + Column( + modifier = Modifier.fillMaxSize() + ) { + BodyM( + text = stringResource(R.string.sweep__no_funds_description), + color = Colors.White64, + ) + + Spacer(modifier = Modifier.weight(1f)) + + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + ) { + Image( + painter = painterResource(id = R.drawable.check), + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = Modifier.size(311.dp) + ) + } + + Spacer(modifier = Modifier.weight(1f)) + + PrimaryButton( + text = stringResource(R.string.common__ok), + onClick = onBack, + modifier = Modifier.fillMaxWidth() + ) + + VerticalSpacer(16.dp) + } +} + +@Composable +private fun ErrorView( + message: String, + onRetry: () -> Unit, +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxSize() + ) { + Spacer(modifier = Modifier.weight(1f)) + + Icon( + painter = painterResource(id = R.drawable.ic_warning), + contentDescription = null, + tint = Colors.Red, + modifier = Modifier.size(64.dp) + ) + + VerticalSpacer(24.dp) + + BodySSB(text = stringResource(R.string.sweep__error_title)) + + VerticalSpacer(8.dp) + + BodyM( + text = message, + color = Colors.White64, + ) + + Spacer(modifier = Modifier.weight(1f)) + + PrimaryButton( + text = stringResource(R.string.common__retry), + onClick = onRetry, + modifier = Modifier.fillMaxWidth() + ) + + VerticalSpacer(16.dp) + } +} + +@Preview +@Composable +private fun PreviewLoading() { + AppThemeSurface { + Content( + uiState = SweepUiState(checkState = CheckState.Checking), + ) + } +} + +@Preview +@Composable +private fun PreviewFound() { + AppThemeSurface { + Content( + uiState = SweepUiState( + checkState = CheckState.Found(100000u), + sweepableBalances = SweepableBalances( + legacyBalance = 50000u, + legacyUtxosCount = 2u, + p2shBalance = 30000u, + p2shUtxosCount = 1u, + taprootBalance = 20000u, + taprootUtxosCount = 1u, + ), + ), + ) + } +} + +@Preview +@Composable +private fun PreviewNoFunds() { + AppThemeSurface { + Content( + uiState = SweepUiState(checkState = CheckState.NoFunds), + ) + } +} diff --git a/app/src/main/java/to/bitkit/ui/settings/advanced/sweep/SweepSuccessScreen.kt b/app/src/main/java/to/bitkit/ui/settings/advanced/sweep/SweepSuccessScreen.kt new file mode 100644 index 000000000..ead5d78ea --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/settings/advanced/sweep/SweepSuccessScreen.kt @@ -0,0 +1,127 @@ +package to.bitkit.ui.settings.advanced.sweep + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +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.dp +import androidx.navigation.NavController +import com.airbnb.lottie.compose.LottieAnimation +import com.airbnb.lottie.compose.LottieCompositionSpec +import com.airbnb.lottie.compose.rememberLottieComposition +import to.bitkit.R +import to.bitkit.ui.Routes +import to.bitkit.ui.components.BalanceHeaderView +import to.bitkit.ui.components.BodyM +import to.bitkit.ui.components.PrimaryButton +import to.bitkit.ui.components.VerticalSpacer +import to.bitkit.ui.scaffold.AppTopBar +import to.bitkit.ui.scaffold.DrawerNavIcon +import to.bitkit.ui.scaffold.ScreenColumn +import to.bitkit.ui.theme.AppThemeSurface +import to.bitkit.ui.theme.Colors + +@Composable +fun SweepSuccessScreen( + navController: NavController, + amountSats: Long, +) { + Content( + amountSats = amountSats, + onDone = { + navController.navigate(Routes.Home) { + popUpTo(Routes.Home) { inclusive = true } + } + }, + ) +} + +@Composable +private fun Content( + amountSats: Long = 0L, + onDone: () -> Unit = {}, +) { + val composition by rememberLottieComposition(LottieCompositionSpec.RawRes(R.raw.confetti_orange)) + + Box(modifier = Modifier.fillMaxSize()) { + LottieAnimation( + composition = composition, + iterations = 100, + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxSize() + .testTag("confetti_animation") + ) + + ScreenColumn(noBackground = true) { + AppTopBar( + titleText = stringResource(R.string.sweep__success_nav_title), + onBackClick = null, + actions = { DrawerNavIcon() }, + ) + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .testTag("SweepSuccess") + ) { + VerticalSpacer(16.dp) + + BodyM( + text = stringResource(R.string.sweep__success_description), + color = Colors.White64, + ) + + VerticalSpacer(16.dp) + + BalanceHeaderView(sats = amountSats) + + Spacer(modifier = Modifier.weight(1f)) + + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxWidth() + ) { + Image( + painter = painterResource(R.drawable.check), + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = Modifier.size(256.dp) + ) + } + + Spacer(modifier = Modifier.weight(1f)) + + PrimaryButton( + text = stringResource(R.string.sweep__success_wallet_overview), + onClick = onDone, + modifier = Modifier.fillMaxWidth() + ) + + VerticalSpacer(16.dp) + } + } + } +} + +@Preview(showSystemUi = true, showBackground = true) +@Composable +private fun Preview() { + AppThemeSurface { + Content(amountSats = 18000L) + } +} diff --git a/app/src/main/java/to/bitkit/ui/sheets/SweepPromptSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/SweepPromptSheet.kt new file mode 100644 index 000000000..14859ab62 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/sheets/SweepPromptSheet.kt @@ -0,0 +1,121 @@ +package to.bitkit.ui.sheets + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +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.dp +import to.bitkit.R +import to.bitkit.ui.components.BodyM +import to.bitkit.ui.components.BottomSheetPreview +import to.bitkit.ui.components.Display +import to.bitkit.ui.components.PrimaryButton +import to.bitkit.ui.components.SecondaryButton +import to.bitkit.ui.components.VerticalSpacer +import to.bitkit.ui.scaffold.SheetTopBar +import to.bitkit.ui.shared.modifiers.sheetHeight +import to.bitkit.ui.shared.util.gradientBackground +import to.bitkit.ui.theme.AppThemeSurface +import to.bitkit.ui.theme.Colors +import to.bitkit.ui.utils.withAccent + +@Composable +fun SweepPromptSheet( + onSweep: () -> Unit, + onCancel: () -> Unit, +) { + Content( + onSweep = onSweep, + onCancel = onCancel, + ) +} + +@Composable +private fun Content( + modifier: Modifier = Modifier, + onSweep: () -> Unit = {}, + onCancel: () -> Unit = {}, +) { + Column( + modifier = modifier + .sheetHeight() + .gradientBackground() + .navigationBarsPadding() + .padding(horizontal = 16.dp) + .testTag("SweepPromptSheet") + ) { + SheetTopBar(titleText = stringResource(R.string.sweep__nav_title)) + + Box( + contentAlignment = Alignment.BottomCenter, + modifier = Modifier + .fillMaxWidth() + .weight(1f) + ) { + Image( + painter = painterResource(R.drawable.coin_stack_x), + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = Modifier.size(311.dp) + ) + } + + VerticalSpacer(16.dp) + + Display(text = stringResource(R.string.sweep__prompt_title).withAccent()) + + VerticalSpacer(8.dp) + + BodyM( + text = stringResource(R.string.sweep__prompt_description), + color = Colors.White64, + modifier = Modifier.fillMaxWidth() + ) + + VerticalSpacer(32.dp) + + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + SecondaryButton( + text = stringResource(R.string.common__cancel), + onClick = onCancel, + modifier = Modifier + .weight(1f) + .testTag("CancelButton") + ) + PrimaryButton( + text = stringResource(R.string.sweep__prompt_sweep), + onClick = onSweep, + modifier = Modifier + .weight(1f) + .testTag("SweepButton") + ) + } + + VerticalSpacer(16.dp) + } +} + +@Preview(showSystemUi = true) +@Composable +private fun Preview() { + AppThemeSurface { + BottomSheetPreview { + Content() + } + } +} diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index c249a4b9c..b00cd2c18 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -95,6 +95,7 @@ import to.bitkit.repositories.CurrencyRepo import to.bitkit.repositories.HealthRepo import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.PreActivityMetadataRepo +import to.bitkit.repositories.SweepRepo import to.bitkit.repositories.TransferRepo import to.bitkit.repositories.WalletRepo import to.bitkit.services.AppUpdaterService @@ -142,6 +143,7 @@ class AppViewModel @Inject constructor( private val cacheStore: CacheStore, private val transferRepo: TransferRepo, private val migrationService: MigrationService, + private val sweepRepo: SweepRepo, private val appUpdateSheet: AppUpdateTimedSheet, private val backupSheet: BackupTimedSheet, private val notificationsSheet: NotificationsTimedSheet, @@ -376,6 +378,7 @@ class AppViewModel @Inject constructor( walletRepo.syncBalances() migrationService.setRestoringFromRNRemoteBackup(false) migrationService.setShowingMigrationLoading(false) + checkForSweepableFunds() } private fun buildChannelMigrationIfAvailable(): ChannelDataMigration? { @@ -424,12 +427,7 @@ class AppViewModel @Inject constructor( migrationService.setShowingMigrationLoading(false) delay(MIGRATION_AUTH_RESET_DELAY_MS) resetIsAuthenticatedStateInternal() - - toast( - type = Toast.ToastType.SUCCESS, - title = "Migration Complete", - description = "Your wallet has been successfully migrated" - ) + checkForSweepableFunds() } private suspend fun finishMigrationWithFallbackSync() { @@ -443,12 +441,7 @@ class AppViewModel @Inject constructor( migrationService.setShowingMigrationLoading(false) delay(MIGRATION_AUTH_RESET_DELAY_MS) resetIsAuthenticatedStateInternal() - - toast( - type = Toast.ToastType.SUCCESS, - title = "Migration Complete", - description = "Your wallet has been successfully migrated" - ) + checkForSweepableFunds() } private suspend fun finishMigrationWithError() { @@ -462,6 +455,13 @@ class AppViewModel @Inject constructor( ) } + fun checkForSweepableFunds() { + viewModelScope.launch(bgDispatcher) { + sweepRepo.hasSweepableFunds() + .onSuccess { hasFunds -> if (hasFunds) showSheet(Sheet.SweepPrompt) } + } + } + private suspend fun handleOnchainTransactionConfirmed(event: Event.OnchainTransactionConfirmed) { activityRepo.handleOnchainTransactionConfirmed(event.txid, event.details) } diff --git a/app/src/main/java/to/bitkit/viewmodels/SweepViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/SweepViewModel.kt new file mode 100644 index 000000000..b6285854c --- /dev/null +++ b/app/src/main/java/to/bitkit/viewmodels/SweepViewModel.kt @@ -0,0 +1,226 @@ +package to.bitkit.viewmodels + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.synonym.bitkitcore.FeeRates +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import to.bitkit.models.TransactionSpeed +import to.bitkit.models.safe +import to.bitkit.repositories.LightningRepo +import to.bitkit.repositories.SweepRepo +import to.bitkit.utils.Logger +import javax.inject.Inject + +@HiltViewModel +class SweepViewModel @Inject constructor( + private val sweepRepo: SweepRepo, + private val lightningRepo: LightningRepo, +) : ViewModel() { + private val _uiState = MutableStateFlow(SweepUiState()) + val uiState = _uiState.asStateFlow() + + fun checkBalance() = viewModelScope.launch { + _uiState.update { it.copy(checkState = CheckState.Checking) } + + sweepRepo.checkSweepableBalances().fold( + onSuccess = { balances -> + if (balances.totalBalance > 0u) { + _uiState.update { + it.copy( + checkState = CheckState.Found(balances.totalBalance), + sweepableBalances = balances, + ) + } + } else { + _uiState.update { it.copy(checkState = CheckState.NoFunds) } + } + }, + onFailure = { error -> + Logger.error("Failed to check sweepable balance", error, context = TAG) + _uiState.update { it.copy(checkState = CheckState.Error(error.message ?: "Unknown error")) } + } + ) + } + + fun prepareSweep() = viewModelScope.launch { + _uiState.update { it.copy(sweepState = SweepState.Preparing) } + + val selectedFeeRate = _uiState.value.selectedFeeRate + if (selectedFeeRate == null || selectedFeeRate == 0u) { + val error = "Fee rate not set" + _uiState.update { it.copy(sweepState = SweepState.Error(error), errorMessage = error) } + return@launch + } + + val existingAddress = _uiState.value.destinationAddress + var destinationAddress = existingAddress + if (existingAddress == null) { + lightningRepo.newAddress().fold( + onSuccess = { address -> + destinationAddress = address + _uiState.update { it.copy(destinationAddress = address) } + }, + onFailure = { error -> + Logger.error("Failed to get destination address", error, context = TAG) + val errorMsg = "Failed to get destination address" + _uiState.update { + it.copy(sweepState = SweepState.Error(errorMsg), errorMessage = errorMsg) + } + return@launch + } + ) + } + + if (destinationAddress == null) return@launch + + sweepRepo.prepareSweepTransaction( + destinationAddress = destinationAddress, + feeRateSatsPerVbyte = selectedFeeRate, + ).fold( + onSuccess = { preview -> + _uiState.update { + it.copy( + sweepState = SweepState.Ready, + transactionPreview = preview, + ) + } + }, + onFailure = { error -> + Logger.error("Failed to prepare sweep", error, context = TAG) + _uiState.update { + it.copy( + sweepState = SweepState.Error(error.message ?: "Unknown error"), + errorMessage = error.message, + ) + } + } + ) + } + + fun broadcastSweep() = viewModelScope.launch { + val preview = _uiState.value.transactionPreview + if (preview == null) { + _uiState.update { it.copy(sweepState = SweepState.Error("No transaction prepared")) } + return@launch + } + + _uiState.update { it.copy(sweepState = SweepState.Broadcasting) } + + sweepRepo.broadcastSweepTransaction(preview.psbt).fold( + onSuccess = { result -> + _uiState.update { + it.copy( + sweepState = SweepState.Success(result.txid), + sweepResult = result, + ) + } + }, + onFailure = { error -> + Logger.error("Failed to broadcast sweep", error, context = TAG) + _uiState.update { + it.copy( + sweepState = SweepState.Error(error.message ?: "Unknown error"), + errorMessage = error.message, + ) + } + } + ) + } + + fun setFeeRate(speed: TransactionSpeed) { + _uiState.update { it.copy(selectedSpeed = speed) } + + val feeRate: UInt = when (speed) { + is TransactionSpeed.Custom -> speed.satsPerVByte + else -> { + val rates = _uiState.value.feeRates ?: return + speed.getFeeRate(rates) + } + } + _uiState.update { it.copy(selectedFeeRate = feeRate) } + } + + fun loadFeeEstimates() = viewModelScope.launch { + sweepRepo.getFeeRates().fold( + onSuccess = { rates -> + _uiState.update { it.copy(feeRates = rates) } + if (_uiState.value.selectedFeeRate == null) { + setFeeRate(_uiState.value.selectedSpeed) + } + }, + onFailure = { error -> + Logger.error("Failed to load fee estimates", error, context = TAG) + _uiState.update { it.copy(errorMessage = error.message) } + } + ) + } + + fun reset() { + _uiState.update { SweepUiState() } + } + + companion object { + private const val TAG = "SweepViewModel" + } +} + +data class SweepUiState( + val checkState: CheckState = CheckState.Idle, + val sweepState: SweepState = SweepState.Idle, + val sweepableBalances: SweepableBalances? = null, + val transactionPreview: SweepTransactionPreview? = null, + val sweepResult: SweepResult? = null, + val destinationAddress: String? = null, + val selectedSpeed: TransactionSpeed = TransactionSpeed.Medium, + val selectedFeeRate: UInt? = null, + val feeRates: FeeRates? = null, + val errorMessage: String? = null, +) { + val estimatedFee: ULong get() = transactionPreview?.estimatedFee ?: 0u +} + +sealed interface CheckState { + data object Idle : CheckState + data object Checking : CheckState + data class Found(val balance: ULong) : CheckState + data object NoFunds : CheckState + data class Error(val message: String) : CheckState +} + +sealed interface SweepState { + data object Idle : SweepState + data object Preparing : SweepState + data object Ready : SweepState + data object Broadcasting : SweepState + data class Success(val txid: String) : SweepState + data class Error(val message: String) : SweepState +} + +data class SweepableBalances( + val legacyBalance: ULong = 0u, + val legacyUtxosCount: UInt = 0u, + val p2shBalance: ULong = 0u, + val p2shUtxosCount: UInt = 0u, + val taprootBalance: ULong = 0u, + val taprootUtxosCount: UInt = 0u, +) { + val totalBalance: ULong + get() = listOf(legacyBalance, p2shBalance, taprootBalance) + .fold(0uL) { acc, balance -> (acc.safe() + balance.safe()) } +} + +data class SweepTransactionPreview( + val psbt: String, + val estimatedFee: ULong, + val amountAfterFees: ULong, + val estimatedVsize: ULong, +) + +data class SweepResult( + val txid: String, + val amountSwept: ULong, +) diff --git a/app/src/main/res/drawable-nodpi/magnifying_glass.png b/app/src/main/res/drawable-nodpi/magnifying_glass.png new file mode 100644 index 000000000..2f6597e49 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/magnifying_glass.png differ diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index fb8a44b73..41dbb878d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1196,4 +1196,40 @@ Next Outbound HTLC Limit Next Outbound HTLC Min Confirmations + Please wait while your old wallet data migrates to this new Bitkit version... + Wallet Migration + MIGRATING\n<accent>WALLET</accent> + %1$s sats + Please wait while Bitkit looks for funds in unsupported addresses (Legacy, Nested SegWit, and Taproot). + LOOKING FOR FUNDS... + Looking For Funds + BROADCASTING… + Confirm Sweep + PREPARING… + Retry + Swipe to confirm + To Address + Custom Fee + for this transaction + Error + Network Fee + Bitkit found funds in unsupported addresses (Legacy, Nested SegWit, and Taproot). + FUNDS FOUND + Found Funds + Legacy (P2PKH) + Sweep Funds + Bitkit checked unsupported address types and found no funds to sweep. + No Funds To Sweep + Bitkit found funds on unsupported Bitcoin address types. Sweep to move the funds to your new wallet balance. + Sweep + SWEEP OLD\n<accent>BITKIT FUNDS</accent> + SegWit (P2SH) + Your funds have been swept and will be added to your wallet balance. + Sweep Complete + Wallet Overview + Taproot (P2TR) + Sweep To Wallet + Total + %1$s, %2$d UTXO + %1$s, %2$d UTXOs diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a95f687e5..95a33d23b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -19,7 +19,7 @@ activity-compose = { module = "androidx.activity:activity-compose", version = "1 appcompat = { module = "androidx.appcompat:appcompat", version = "1.7.1" } barcode-scanning = { module = "com.google.mlkit:barcode-scanning", version = "17.3.0" } biometric = { module = "androidx.biometric:biometric", version = "1.4.0-alpha04" } -bitkit-core = { module = "com.synonym:bitkit-core-android", version = "0.1.33" } +bitkit-core = { module = "com.synonym:bitkit-core-android", version = "0.1.35" } bouncycastle-provider-jdk = { module = "org.bouncycastle:bcprov-jdk18on", version = "1.83" } camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "camera" } camera-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "camera" }