From 5de8f3f795fdd7c4bdcdd549a12230acee445b8b Mon Sep 17 00:00:00 2001 From: a1denvalu3 Date: Tue, 17 Feb 2026 11:50:48 +0100 Subject: [PATCH 1/2] feat: Add Cashu token withdrawal support - Add WithdrawTokenActivity for creating Cashu tokens - Add WithdrawTokenSuccessActivity for displaying created tokens - Add UI entry point in AutoWithdrawSettingsActivity - Add clean iOS-style design with animations - Add quick amount buttons and input validation - Add token copy/share functionality Users can now withdraw their balance as Cashu tokens to transfer to another wallet, in addition to Lightning withdrawals. --- app/src/main/AndroidManifest.xml | 24 ++ .../AutoWithdrawSettingsActivity.kt | 55 +++ .../feature/settings/WithdrawTokenActivity.kt | 332 ++++++++++++++++++ .../settings/WithdrawTokenSuccessActivity.kt | 220 ++++++++++++ .../res/drawable/bg_button_quick_amount.xml | 9 + app/src/main/res/drawable/ic_cashu_symbol.xml | 10 + .../activity_auto_withdraw_settings.xml | 69 ++++ .../res/layout/activity_withdraw_token.xml | 314 +++++++++++++++++ .../activity_withdraw_token_success.xml | 167 +++++++++ app/src/main/res/values/colors.xml | 5 + app/src/main/res/values/strings.xml | 26 ++ app/src/main/res/values/styles.xml | 10 + 12 files changed, 1241 insertions(+) create mode 100644 app/src/main/java/com/electricdreams/numo/feature/settings/WithdrawTokenActivity.kt create mode 100644 app/src/main/java/com/electricdreams/numo/feature/settings/WithdrawTokenSuccessActivity.kt create mode 100644 app/src/main/res/drawable/bg_button_quick_amount.xml create mode 100644 app/src/main/res/drawable/ic_cashu_symbol.xml create mode 100644 app/src/main/res/layout/activity_withdraw_token.xml create mode 100644 app/src/main/res/layout/activity_withdraw_token_success.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 7e4a5bdd..2b8fee71 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -355,6 +355,30 @@ android:value=".ModernPOSActivity" /> + + + + + + + + 0 } + + if (mintsWithBalance.isEmpty()) { + // No balance to withdraw - show a nice toast instead of dialog + Toast.makeText( + this@AutoWithdrawSettingsActivity, + R.string.manual_withdraw_no_balance, + Toast.LENGTH_LONG + ).show() + return@launch + } + + // Show beautiful bottom sheet + val bottomSheet = MintSelectionBottomSheet.newInstance( + mintBalances = mintsWithBalance, + listener = object : MintSelectionBottomSheet.OnMintSelectedListener { + override fun onMintSelected(mintUrl: String, balance: Long) { + openWithdrawTokenScreen(mintUrl, balance) + } + } + ) + bottomSheet.show(supportFragmentManager, "MintSelectionBottomSheet") + } + } + + /** + * Open the token withdraw screen for the selected mint + */ + private fun openWithdrawTokenScreen(mintUrl: String, balance: Long) { + val intent = Intent(this, WithdrawTokenActivity::class.java).apply { + putExtra("mint_url", mintUrl) + putExtra("balance", balance) + } + startActivity(intent) + } + private fun showThresholdEditDialog() { DialogHelper.showInput( context = this, diff --git a/app/src/main/java/com/electricdreams/numo/feature/settings/WithdrawTokenActivity.kt b/app/src/main/java/com/electricdreams/numo/feature/settings/WithdrawTokenActivity.kt new file mode 100644 index 00000000..8f7cdd65 --- /dev/null +++ b/app/src/main/java/com/electricdreams/numo/feature/settings/WithdrawTokenActivity.kt @@ -0,0 +1,332 @@ +package com.electricdreams.numo.feature.settings + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.util.Log +import android.view.View +import android.view.animation.AccelerateDecelerateInterpolator +import android.view.animation.OvershootInterpolator +import android.widget.Button +import android.widget.EditText +import android.widget.FrameLayout +import android.widget.ImageButton +import android.widget.TextView +import android.widget.Toast +import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.WindowCompat +import androidx.lifecycle.lifecycleScope +import com.electricdreams.numo.R +import com.electricdreams.numo.core.cashu.CashuWalletManager +import com.electricdreams.numo.core.model.Amount +import com.electricdreams.numo.core.util.MintManager +import com.google.android.material.card.MaterialCardView +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.cashudevkit.Amount as CdkAmount +import org.cashudevkit.CurrencyUnit +import org.cashudevkit.MintUrl +import org.cashudevkit.PaymentMethod +import org.cashudevkit.SplitTarget +import org.cashudevkit.Token + +/** + * Premium Apple-like activity for withdrawing balance from a mint as Cashu tokens. + * + * Features: + * - Beautiful card-based design + * - Quick amount buttons + * - Smooth entrance animations + * - Elegant loading states + * - Token display with copy/share functionality + */ +class WithdrawTokenActivity : AppCompatActivity() { + + companion object { + private const val TAG = "WithdrawToken" + } + + private lateinit var mintUrl: String + private var balance: Long = 0 + private lateinit var mintManager: MintManager + + // Views + private lateinit var backButton: ImageButton + private lateinit var balanceCard: MaterialCardView + private lateinit var mintNameText: TextView + private lateinit var balanceText: TextView + private lateinit var amountCard: MaterialCardView + private lateinit var amountInput: EditText + private lateinit var withdrawButton: Button + private lateinit var btnAmount100: Button + private lateinit var btnAmount500: Button + private lateinit var btnAmount1000: Button + private lateinit var btnAmountMax: Button + private lateinit var loadingOverlay: FrameLayout + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_withdraw_token) + + // Enable edge-to-edge + WindowCompat.setDecorFitsSystemWindows(window, false) + window.statusBarColor = android.graphics.Color.TRANSPARENT + + mintUrl = intent.getStringExtra("mint_url") ?: "" + balance = intent.getLongExtra("balance", 0) + mintManager = MintManager.getInstance(this) + + if (mintUrl.isEmpty()) { + Toast.makeText( + this, + getString(R.string.withdraw_token_error_generic, "Invalid mint URL"), + Toast.LENGTH_SHORT + ).show() + finish() + return + } + + initViews() + setupListeners() + displayMintInfo() + startEntranceAnimations() + } + + private fun initViews() { + backButton = findViewById(R.id.back_button) + balanceCard = findViewById(R.id.balance_card) + mintNameText = findViewById(R.id.mint_name_text) + balanceText = findViewById(R.id.balance_text) + amountCard = findViewById(R.id.amount_card) + amountInput = findViewById(R.id.amount_input) + withdrawButton = findViewById(R.id.withdraw_button) + btnAmount100 = findViewById(R.id.btn_amount_100) + btnAmount500 = findViewById(R.id.btn_amount_500) + btnAmount1000 = findViewById(R.id.btn_amount_1000) + btnAmountMax = findViewById(R.id.btn_amount_max) + loadingOverlay = findViewById(R.id.loading_overlay) + } + + private fun setupListeners() { + backButton.setOnClickListener { + finish() + } + + // Amount input text change listener + amountInput.setOnFocusChangeListener { _, hasFocus -> + if (!hasFocus) { + validateAndUpdateButton() + } + } + + // Quick amount buttons + btnAmount100.setOnClickListener { setAmount(100) } + btnAmount500.setOnClickListener { setAmount(500) } + btnAmount1000.setOnClickListener { setAmount(1000) } + btnAmountMax.setOnClickListener { setAmount(balance.toInt()) } + + // Withdraw button + withdrawButton.setOnClickListener { + createToken() + } + } + + private fun setAmount(amount: Int) { + amountInput.setText(amount.toString()) + validateAndUpdateButton() + } + + private fun validateAndUpdateButton() { + val amountText = amountInput.text?.toString() + val amount = amountText?.toLongOrNull() ?: 0 + + val isValid = amount > 0 && amount <= balance + withdrawButton.isEnabled = isValid + withdrawButton.alpha = if (isValid) 1f else 0.5f + } + + private fun displayMintInfo() { + val displayName = mintManager.getMintDisplayName(mintUrl) + mintNameText.text = displayName + + val balanceAmount = Amount(balance, Amount.Currency.BTC) + balanceText.text = balanceAmount.toString() + } + + private fun startEntranceAnimations() { + // Balance card slide in from top + balanceCard.alpha = 0f + balanceCard.translationY = -40f + balanceCard.animate() + .alpha(1f) + .translationY(0f) + .setDuration(400) + .setInterpolator(AccelerateDecelerateInterpolator()) + .start() + + // Balance text scale + balanceText.scaleX = 0.8f + balanceText.scaleY = 0.8f + balanceText.animate() + .scaleX(1f) + .scaleY(1f) + .setStartDelay(200) + .setDuration(350) + .setInterpolator(OvershootInterpolator(2f)) + .start() + + // Amount card stagger entrance + amountCard.alpha = 0f + amountCard.translationY = 40f + amountCard.animate() + .alpha(1f) + .translationY(0f) + .setStartDelay(300) + .setDuration(400) + .setInterpolator(AccelerateDecelerateInterpolator()) + .start() + } + + private fun createToken() { + val amountText = amountInput.text?.toString() + val amount = amountText?.toLongOrNull() ?: 0 + + if (amount <= 0) { + Toast.makeText( + this, + getString(R.string.withdraw_token_error_invalid_amount), + Toast.LENGTH_SHORT + ).show() + return + } + + if (amount > balance) { + Toast.makeText( + this, + getString(R.string.withdraw_token_error_insufficient_balance, balance), + Toast.LENGTH_SHORT + ).show() + return + } + + setLoading(true) + + lifecycleScope.launch { + try { + val wallet = CashuWalletManager.getWallet() + if (wallet == null) { + withContext(Dispatchers.Main) { + Toast.makeText( + this@WithdrawTokenActivity, + getString(R.string.withdraw_token_error_wallet_not_initialized), + Toast.LENGTH_SHORT + ).show() + setLoading(false) + } + return@launch + } + + // Get wallet for the mint + val mintWallet = wallet.getWallet(MintUrl(mintUrl), CurrencyUnit.Sat) + if (mintWallet == null) { + throw Exception("Failed to get wallet for mint: $mintUrl") + } + + // Create a mint quote for Cashu tokens + val splitTarget = SplitTarget( + CdkAmount(amount.toULong()), + CdkAmount(0u) + ) + + // Request tokens from the mint + val token = withContext(Dispatchers.IO) { + mintWallet.mintQuote( + PaymentMethod.Cashu, + CdkAmount(amount.toULong()), + "Numo token withdrawal", + null + ) + } + + withContext(Dispatchers.Main) { + setLoading(false) + + // The mintQuote returns a Token directly for Cashu method + // Launch success activity with the token + launchTokenSuccessActivity(token, amount) + } + } catch (e: Exception) { + Log.e(TAG, "Error creating token", e) + withContext(Dispatchers.Main) { + setLoading(false) + Toast.makeText( + this@WithdrawTokenActivity, + getString(R.string.withdraw_token_error_generic, e.message ?: "Unknown error"), + Toast.LENGTH_LONG + ).show() + } + } + } + } + + private fun launchTokenSuccessActivity(token: Token, amount: Long) { + val tokenString = token.encode() + + val intent = Intent(this, WithdrawTokenSuccessActivity::class.java) + intent.putExtra("mint_url", mintUrl) + intent.putExtra("amount", amount) + intent.putExtra("token", tokenString) + startActivity(intent) + } + + private fun setLoading(loading: Boolean) { + loadingOverlay.visibility = if (loading) View.VISIBLE else View.GONE + + // Animate loading overlay + if (loading) { + loadingOverlay.alpha = 0f + loadingOverlay.animate() + .alpha(1f) + .setDuration(200) + .start() + } + + // Disable inputs during loading + amountInput.isEnabled = !loading + withdrawButton.isEnabled = !loading && (amountInput.text?.toString()?.toLongOrNull() ?: 0) > 0 + } + + override fun onResume() { + super.onResume() + // Refresh balance + refreshBalance() + } + + /** + * Refreshes the balance from the wallet and updates the UI. + */ + private fun refreshBalance() { + lifecycleScope.launch { + try { + val newBalance = withContext(Dispatchers.IO) { + CashuWalletManager.getBalanceForMint(mintUrl) + } + + withContext(Dispatchers.Main) { + if (newBalance != balance) { + balance = newBalance + val balanceAmount = Amount(balance, Amount.Currency.BTC) + balanceText.text = balanceAmount.toString() + validateAndUpdateButton() + Log.d(TAG, "Balance updated to: $balance sats") + } + } + } catch (e: Exception) { + Log.e(TAG, "Error refreshing balance", e) + } + } + } +} diff --git a/app/src/main/java/com/electricdreams/numo/feature/settings/WithdrawTokenSuccessActivity.kt b/app/src/main/java/com/electricdreams/numo/feature/settings/WithdrawTokenSuccessActivity.kt new file mode 100644 index 00000000..a7ffdabe --- /dev/null +++ b/app/src/main/java/com/electricdreams/numo/feature/settings/WithdrawTokenSuccessActivity.kt @@ -0,0 +1,220 @@ +package com.electricdreams.numo.feature.settings + +import android.animation.AnimatorSet +import android.animation.ObjectAnimator +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.view.View +import android.view.animation.OvershootInterpolator +import android.widget.Button +import android.widget.ImageButton +import android.widget.ImageView +import android.widget.TextView +import android.widget.Toast +import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.WindowCompat +import com.electricdreams.numo.R +import com.electricdreams.numo.core.model.Amount +import com.electricdreams.numo.core.util.BalanceRefreshBroadcast + +/** + * Success screen showing the created Cashu token with copy/share options. + * Following Cash App design guidelines. + */ +class WithdrawTokenSuccessActivity : AppCompatActivity() { + + private lateinit var amountText: TextView + private lateinit var tokenText: TextView + private lateinit var tokenContainer: View + private lateinit var checkmarkCircle: ImageView + private lateinit var checkmarkIcon: ImageView + private lateinit var copyButton: Button + private lateinit var shareButton: Button + private lateinit var closeButton: Button + + private var tokenString: String = "" + private var amount: Long = 0 + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_withdraw_token_success) + + // Enable edge-to-edge + WindowCompat.setDecorFitsSystemWindows(window, false) + window.statusBarColor = android.graphics.Color.TRANSPARENT + window.navigationBarColor = android.graphics.Color.TRANSPARENT + + // Set light status bar icons (since background is white) + val windowInsetsController = WindowCompat.getInsetsController(window, window.decorView) + windowInsetsController.isAppearanceLightStatusBars = true + windowInsetsController.isAppearanceLightNavigationBars = true + + // Adjust padding for system bars + findViewById(android.R.id.content).setOnApplyWindowInsetsListener { v, windowInsets -> + val insets = windowInsets.getInsets(androidx.core.view.WindowInsetsCompat.Type.systemBars()) + v.setPadding(0, insets.top, 0, insets.bottom) + windowInsets + } + + // Get data from intent + amount = intent.getLongExtra("amount", 0) + tokenString = intent.getStringExtra("token") ?: "" + + if (tokenString.isEmpty()) { + Toast.makeText(this, "No token received", Toast.LENGTH_SHORT).show() + finish() + return + } + + initViews() + displayData() + setupListeners() + startAnimations() + + // Refresh balance after withdrawal + val mintUrl = intent.getStringExtra("mint_url") + if (!mintUrl.isNullOrEmpty()) { + BalanceRefreshBroadcast.sendBroadcast(this, "token_withdrawal") + } + } + + private fun initViews() { + amountText = findViewById(R.id.amount_text) + tokenText = findViewById(R.id.token_text) + tokenContainer = findViewById(R.id.token_container) + checkmarkCircle = findViewById(R.id.checkmark_circle) + checkmarkIcon = findViewById(R.id.checkmark_icon) + copyButton = findViewById(R.id.copy_button) + shareButton = findViewById(R.id.share_button) + closeButton = findViewById(R.id.close_button) + } + + private fun displayData() { + // Display amount + val amountObj = Amount(amount, Amount.Currency.BTC) + amountText.text = getString( + R.string.withdraw_token_success_amount, + amountObj.toString() + ) + + // Display token (truncated for UI, full token is saved) + val displayToken = if (tokenString.length > 50) { + tokenString.take(25) + "..." + tokenString.takeLast(20) + } else { + tokenString + } + tokenText.text = displayToken + } + + private fun setupListeners() { + // Copy button + copyButton.setOnClickListener { + copyTokenToClipboard() + } + + // Share button + shareButton.setOnClickListener { + shareToken() + } + + // Close button + closeButton.setOnClickListener { + finish() + } + + // Token container - also copies on tap + tokenContainer.setOnClickListener { + copyTokenToClipboard() + } + } + + private fun copyTokenToClipboard() { + val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clip = ClipData.newPlainText("Cashu Token", tokenString) + clipboard.setPrimaryClip(clip) + + Toast.makeText( + this, + getString(R.string.withdraw_token_success_copied), + Toast.LENGTH_SHORT + ).show() + } + + private fun shareToken() { + val shareIntent = Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, tokenString) + putExtra(Intent.EXTRA_SUBJECT, "Cashu Token - $amount sats") + } + startActivity(Intent.createChooser(shareIntent, getString(R.string.withdraw_token_success_share))) + } + + private fun startAnimations() { + // Checkmark scale animation + checkmarkCircle.scaleX = 0f + checkmarkCircle.scaleY = 0f + checkmarkIcon.scaleX = 0f + checkmarkIcon.scaleY = 0f + + // Animate checkmark circle + checkmarkCircle.animate() + .scaleX(1f) + .scaleY(1f) + .setDuration(500) + .setInterpolator(OvershootInterpolator(2f)) + .setStartDelay(300) + .start() + + // Animate checkmark icon + checkmarkIcon.animate() + .scaleX(1f) + .scaleY(1f) + .setDuration(400) + .setInterpolator(OvershootInterpolator(3f)) + .setStartDelay(500) + .start() + + // Fade in amount and token + amountText.alpha = 0f + tokenContainer.alpha = 0f + copyButton.alpha = 0f + shareButton.alpha = 0f + + amountText.animate() + .alpha(1f) + .setStartDelay(600) + .setDuration(400) + .start() + + tokenContainer.animate() + .alpha(1f) + .setStartDelay(700) + .setDuration(400) + .start() + + copyButton.animate() + .alpha(1f) + .setStartDelay(800) + .setDuration(400) + .start() + + shareButton.animate() + .alpha(1f) + .setStartDelay(850) + .setDuration(400) + .start() + + // Close button + closeButton.alpha = 0f + closeButton.translationY = 20f + closeButton.animate() + .alpha(1f) + .translationY(0f) + .setStartDelay(900) + .setDuration(400) + .start() + } +} diff --git a/app/src/main/res/drawable/bg_button_quick_amount.xml b/app/src/main/res/drawable/bg_button_quick_amount.xml new file mode 100644 index 00000000..ef32e96a --- /dev/null +++ b/app/src/main/res/drawable/bg_button_quick_amount.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_cashu_symbol.xml b/app/src/main/res/drawable/ic_cashu_symbol.xml new file mode 100644 index 00000000..83b3ec1c --- /dev/null +++ b/app/src/main/res/drawable/ic_cashu_symbol.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/layout/activity_auto_withdraw_settings.xml b/app/src/main/res/layout/activity_auto_withdraw_settings.xml index 8093be3f..ef975167 100644 --- a/app/src/main/res/layout/activity_auto_withdraw_settings.xml +++ b/app/src/main/res/layout/activity_auto_withdraw_settings.xml @@ -596,6 +596,75 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/activity_withdraw_token.xml b/app/src/main/res/layout/activity_withdraw_token.xml new file mode 100644 index 00000000..fd94c2e3 --- /dev/null +++ b/app/src/main/res/layout/activity_withdraw_token.xml @@ -0,0 +1,314 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +