From e110b3ff4d4928c0f639aad2cd0f21699b2ffba0 Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Thu, 18 Jun 2026 08:21:59 -0700 Subject: [PATCH 1/3] feat: show messenging to tell users to withdraw from CrowdNode --- .../src/main/res/layout/fragment_explore.xml | 293 ---------------- .../crowdnode/api/CrowdNodeApi.kt | 12 +- wallet/res/navigation/nav_home.xml | 3 +- wallet/res/values/strings.xml | 5 + .../schildbach/wallet/WalletApplication.java | 2 +- .../ui/explore/ExploreEntryViewModel.kt | 22 +- .../wallet/ui/explore/ExploreFragment.kt | 122 ++++--- .../wallet/ui/explore/ExploreScreen.kt | 324 ++++++++++++++++++ .../schildbach/wallet/ui/main/MainActivity.kt | 23 ++ .../wallet/ui/main/MainViewModel.kt | 30 +- .../CrowdNodeWithdrawalReminderDialog.kt | 137 ++++++++ .../wallet/ui/staking/StakingActivity.kt | 26 ++ 12 files changed, 644 insertions(+), 355 deletions(-) delete mode 100644 features/exploredash/src/main/res/layout/fragment_explore.xml create mode 100644 wallet/src/de/schildbach/wallet/ui/explore/ExploreScreen.kt create mode 100644 wallet/src/de/schildbach/wallet/ui/staking/CrowdNodeWithdrawalReminderDialog.kt diff --git a/features/exploredash/src/main/res/layout/fragment_explore.xml b/features/exploredash/src/main/res/layout/fragment_explore.xml deleted file mode 100644 index 18b910c06a..0000000000 --- a/features/exploredash/src/main/res/layout/fragment_explore.xml +++ /dev/null @@ -1,293 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/integrations/crowdnode/src/main/java/org/dash/wallet/integrations/crowdnode/api/CrowdNodeApi.kt b/integrations/crowdnode/src/main/java/org/dash/wallet/integrations/crowdnode/api/CrowdNodeApi.kt index 5438ed0925..33838a5e3a 100644 --- a/integrations/crowdnode/src/main/java/org/dash/wallet/integrations/crowdnode/api/CrowdNodeApi.kt +++ b/integrations/crowdnode/src/main/java/org/dash/wallet/integrations/crowdnode/api/CrowdNodeApi.kt @@ -26,6 +26,8 @@ import androidx.work.workDataOf import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.* import kotlinx.coroutines.flow.* +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import org.bitcoinj.core.Address import org.bitcoinj.core.Coin import org.bitcoinj.core.Transaction @@ -120,6 +122,10 @@ class CrowdNodeApiAggregator @Inject constructor( Executors.newSingleThreadExecutor().asCoroutineDispatcher() ) private var isOnlineStatusRestored: Boolean = false + // Serializes restoreStatus() so concurrent callers (e.g. the init call and the + // blockchain-sync observer) don't both scan the whole wallet and contend on the + // wallet keychain lock at the same time. + private val restoreStatusMutex = Mutex() override val signUpStatus = MutableStateFlow(SignUpStatus.NotStarted) override val onlineAccountStatus = MutableStateFlow(OnlineAccountStatus.None) @@ -162,12 +168,12 @@ class CrowdNodeApiAggregator @Inject constructor( .launchIn(configScope) } - override suspend fun restoreStatus() { + override suspend fun restoreStatus() = restoreStatusMutex.withLock { if (signUpStatus.value == SignUpStatus.NotStarted) { log.info("restoring CrowdNode status") if (isError()) { - return + return@withLock } if (tryRestoreSignUp()) { @@ -175,7 +181,7 @@ class CrowdNodeApiAggregator @Inject constructor( globalConfig.crowdNodeAccountAddress = accountAddress!!.toBase58() restoreCreatedOnlineAccount(accountAddress!!) refreshWithdrawalLimits() - return + return@withLock } val onlineStatusOrdinal = config.get(CrowdNodeConfig.ONLINE_ACCOUNT_STATUS) diff --git a/wallet/res/navigation/nav_home.xml b/wallet/res/navigation/nav_home.xml index 41fe0f7f98..1898e67a95 100644 --- a/wallet/res/navigation/nav_home.xml +++ b/wallet/res/navigation/nav_home.xml @@ -232,8 +232,7 @@ + android:label="Explore"> You need to upgrade your PIN Set up a new PIN Continue + + + You have a balance on CrowdNode + These funds should be withdrawn from CrowdNode. You can transfer these funds to this wallet or via your online account on some other device. + Withdraw funds diff --git a/wallet/src/de/schildbach/wallet/WalletApplication.java b/wallet/src/de/schildbach/wallet/WalletApplication.java index 6c3ff2b53f..304176fad4 100644 --- a/wallet/src/de/schildbach/wallet/WalletApplication.java +++ b/wallet/src/de/schildbach/wallet/WalletApplication.java @@ -300,7 +300,7 @@ public void onCreate() { } - // Initialize AppsFlyer after checking Google Play Services availability + // Initialize AppsFlyer private void initializeAppsFlyer() { try { AppsFlyerLib appsFlyerLib = AppsFlyerLib.getInstance(); diff --git a/wallet/src/de/schildbach/wallet/ui/explore/ExploreEntryViewModel.kt b/wallet/src/de/schildbach/wallet/ui/explore/ExploreEntryViewModel.kt index 92b7ec1aec..a268120975 100644 --- a/wallet/src/de/schildbach/wallet/ui/explore/ExploreEntryViewModel.kt +++ b/wallet/src/de/schildbach/wallet/ui/explore/ExploreEntryViewModel.kt @@ -7,6 +7,8 @@ import androidx.lifecycle.viewModelScope import androidx.lifecycle.asLiveData import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map @@ -49,6 +51,14 @@ class ExploreEntryViewModel @Inject constructor( .map { it != SignUpStatus.NotStarted } .asLiveData() + // Persistent banner: shown whenever the wallet still has a CrowdNode balance to withdraw. + val showWithdrawalBanner: LiveData = combine( + crowdNodeApi.signUpStatus, + crowdNodeApi.balance + ) { status, balance -> + status != SignUpStatus.NotStarted && (balance.data?.isPositive == true) + }.asLiveData() + init { blockchainStateProvider.observeState() .filterNotNull() @@ -92,10 +102,14 @@ class ExploreEntryViewModel @Inject constructor( _isBlockchainSynced.value = state.isSynced() if (state.isSynced()) { - // the sign up status might be restorable from the blockchain now - crowdNodeApi.restoreStatus() - val withoutFees = (100.0 - crowdNodeApi.getFee()) / 100 - _stakingAPY.postValue(withoutFees * blockchainStateProvider.getMasternodeAPY()) + // the sign up status might be restorable from the blockchain now. + // restoreStatus() scans the entire wallet history and acquires the + // wallet keychain lock, so it must not run on the main thread. + withContext(Dispatchers.IO) { + crowdNodeApi.restoreStatus() + val withoutFees = (100.0 - crowdNodeApi.getFee()) / 100 + _stakingAPY.postValue(withoutFees * blockchainStateProvider.getMasternodeAPY()) + } } } } diff --git a/wallet/src/de/schildbach/wallet/ui/explore/ExploreFragment.kt b/wallet/src/de/schildbach/wallet/ui/explore/ExploreFragment.kt index 5c4c353065..6a7312c32b 100644 --- a/wallet/src/de/schildbach/wallet/ui/explore/ExploreFragment.kt +++ b/wallet/src/de/schildbach/wallet/ui/explore/ExploreFragment.kt @@ -20,12 +20,17 @@ package de.schildbach.wallet.ui.explore import android.content.Intent import android.net.Uri import android.os.Bundle +import android.view.LayoutInflater import android.view.View +import android.view.ViewGroup import androidx.activity.result.contract.ActivityResultContracts -import androidx.core.view.isVisible +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import androidx.lifecycle.lifecycleScope +import androidx.navigation.fragment.findNavController import com.google.android.material.transition.MaterialFadeThrough import dagger.hilt.android.AndroidEntryPoint import de.schildbach.wallet.ui.staking.StakingActivity @@ -33,19 +38,24 @@ import de.schildbach.wallet_test.R import kotlinx.coroutines.launch import org.dash.wallet.common.services.analytics.AnalyticsConstants import org.dash.wallet.common.ui.dialogs.AdaptiveDialog -import org.dash.wallet.common.ui.viewBinding import org.dash.wallet.common.util.Constants import org.dash.wallet.common.util.safeNavigate -import org.dash.wallet.features.exploredash.databinding.FragmentExploreBinding import org.dash.wallet.features.exploredash.ui.explore.ExploreTopic import org.dash.wallet.features.exploredash.ui.explore.dialogs.ExploreDashInfoDialog -import java.util.Locale @AndroidEntryPoint -class ExploreFragment : Fragment(R.layout.fragment_explore) { - private val binding by viewBinding(FragmentExploreBinding::bind) +class ExploreFragment : Fragment() { private val viewModel: ExploreEntryViewModel by viewModels() + private val screenState = mutableStateOf( + ExploreScreenState( + showFaucet = false, + showStaking = false, + apy = 0.0, + showWithdrawalBanner = false + ) + ) + private val stakingLauncher = registerForActivityResult( ActivityResultContracts.StartActivityForResult() ) { result -> @@ -54,56 +64,79 @@ class ExploreFragment : Fragment(R.layout.fragment_explore) { } } - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - enterTransition = MaterialFadeThrough() - - binding.merchantsBtn.setOnClickListener { - lifecycleScope.launch { - if (viewModel.isInfoShown()) { - safeNavigate(ExploreFragmentDirections.exploreToSearch(ExploreTopic.Merchants)) - } else { - ExploreDashInfoDialog().show(requireActivity()) { - viewModel.setIsInfoShown(true) - safeNavigate(ExploreFragmentDirections.exploreToSearch(ExploreTopic.Merchants)) + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + return ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + ExploreScreen( + state = screenState.value, + onBackClick = { findNavController().popBackStack() }, + onWhereToSpendClick = ::handleMerchantsNavigation, + onAtmsClick = { + safeNavigate(ExploreFragmentDirections.exploreToSearch(ExploreTopic.ATMs)) + }, + onStakingClick = { + viewModel.logEvent(AnalyticsConstants.CrowdNode.STAKING_ENTRY) + handleStakingNavigation() + }, + onFaucetClick = { + safeNavigate(ExploreFragmentDirections.exploreToFaucet()) + }, + onWithdrawClick = { + viewModel.logEvent(AnalyticsConstants.CrowdNode.STAKING_ENTRY) + handleStakingNavigation(goToWithdraw = true) } - } + ) } } + } - binding.atmsBtn.setOnClickListener { - safeNavigate(ExploreFragmentDirections.exploreToSearch(ExploreTopic.ATMs)) - } + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + enterTransition = MaterialFadeThrough() - // CrowdNode functionality is limited: hide the staking entry point - // unless the active wallet is associated with a CrowdNode account - binding.stakingBtn.isVisible = false - viewModel.hasCrowdNodeAccount.observe(viewLifecycleOwner) { hasAccount -> - binding.stakingBtn.isVisible = hasAccount - } + screenState.value = screenState.value.copy(showFaucet = viewModel.isTestNet()) - binding.stakingBtn.setOnClickListener { - viewModel.logEvent(AnalyticsConstants.CrowdNode.STAKING_ENTRY) - handleStakingNavigation() + // CrowdNode functionality is limited: the staking entry point is only shown + // if the active wallet is already associated with a CrowdNode account + viewModel.hasCrowdNodeAccount.observe(viewLifecycleOwner) { hasAccount -> + screenState.value = screenState.value.copy(showStaking = hasAccount) } - binding.faucetBtn.isVisible = viewModel.isTestNet() - binding.faucetBtn.setOnClickListener { - safeNavigate(ExploreFragmentDirections.exploreToFaucet()) + viewModel.stakingAPY.observe(viewLifecycleOwner) { apy -> + screenState.value = screenState.value.copy(apy = apy) } - viewModel.stakingAPY.observe(viewLifecycleOwner) { - setAPY(it) + // Persistent, non-dismissible reminder to withdraw a remaining CrowdNode balance. + viewModel.showWithdrawalBanner.observe(viewLifecycleOwner) { show -> + screenState.value = screenState.value.copy(showWithdrawalBanner = show) } // load the last APY value viewModel.getLastStakingAPY() } - private fun handleStakingNavigation() { + private fun handleMerchantsNavigation() { + lifecycleScope.launch { + if (viewModel.isInfoShown()) { + safeNavigate(ExploreFragmentDirections.exploreToSearch(ExploreTopic.Merchants)) + } else { + ExploreDashInfoDialog().show(requireActivity()) { + viewModel.setIsInfoShown(true) + safeNavigate(ExploreFragmentDirections.exploreToSearch(ExploreTopic.Merchants)) + } + } + } + } + + private fun handleStakingNavigation(goToWithdraw: Boolean = false) { lifecycleScope.launch { if (viewModel.isBlockchainSynced.value == true) { - stakingLauncher.launch(Intent(requireContext(), StakingActivity::class.java)) + stakingLauncher.launch(StakingActivity.createIntent(requireContext(), goToWithdraw)) } else { val openWebsite = AdaptiveDialog.create( null, @@ -120,17 +153,4 @@ class ExploreFragment : Fragment(R.layout.fragment_explore) { } } } - - private fun setAPY(apy: Double) { - if (apy != 0.0) { - binding.stakingApyContainer.isVisible = true - binding.stakingApy.text = getString( - R.string.explore_staking_current_apy, - String.format(Locale.getDefault(), "%.1f", apy) - ) - } else { - // hide the APY container if we don't have a value yet - binding.stakingApyContainer.isVisible = false - } - } } diff --git a/wallet/src/de/schildbach/wallet/ui/explore/ExploreScreen.kt b/wallet/src/de/schildbach/wallet/ui/explore/ExploreScreen.kt new file mode 100644 index 0000000000..be63ee8980 --- /dev/null +++ b/wallet/src/de/schildbach/wallet/ui/explore/ExploreScreen.kt @@ -0,0 +1,324 @@ +/* + * Copyright (c) 2025 Dash Core Group + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package de.schildbach.wallet.ui.explore + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Text +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.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import de.schildbach.wallet_test.R +import org.dash.wallet.common.ui.components.DashButton +import org.dash.wallet.common.ui.components.MenuItem +import org.dash.wallet.common.ui.components.MyTheme +import org.dash.wallet.common.ui.components.NavBarBack +import org.dash.wallet.common.ui.components.Size +import org.dash.wallet.common.ui.components.Style + +data class ExploreScreenState( + val showFaucet: Boolean, // testnet only + val showStaking: Boolean, // has CrowdNode account + val apy: Double, // 0.0 means hide the APY badge + val showWithdrawalBanner: Boolean +) + +@Composable +fun ExploreScreen( + state: ExploreScreenState, + onBackClick: () -> Unit, + onWhereToSpendClick: () -> Unit, + onAtmsClick: () -> Unit, + onStakingClick: () -> Unit, + onFaucetClick: () -> Unit, + onWithdrawClick: () -> Unit, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .fillMaxWidth() + .background(MyTheme.Colors.backgroundPrimary) + ) { + // Fixed back chevron pinned to the top, above the scrolling content + NavBarBack(onBackClick = onBackClick) + + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 20.dp) + .padding(bottom = 20.dp), + verticalArrangement = Arrangement.spacedBy(20.dp) + ) { + // Title + subtitle (TopIntro pattern, no extra horizontal padding since we already padded) + Column( + modifier = Modifier + .fillMaxWidth() + .padding(end = 40.dp), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + Text( + text = stringResource(R.string.explore_dash), + style = MyTheme.Typography.HeadlineSmallBold, + color = MyTheme.Colors.textPrimary, + modifier = Modifier.fillMaxWidth() + ) + Text( + text = stringResource(R.string.explore_subtitle), + style = MyTheme.Body2Regular, + color = MyTheme.Colors.textSecondary, + modifier = Modifier.fillMaxWidth() + ) + } + + // Menu card with the list of destinations + Box( + modifier = Modifier + .fillMaxWidth() + .background(MyTheme.Colors.backgroundSecondary, RoundedCornerShape(20.dp)) + .padding(6.dp) + ) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + if (state.showFaucet) { + MenuItem( + title = stringResource(R.string.explore_get_test_dash), + subtitle = stringResource(R.string.explore_test_dash_text_1), + icon = R.drawable.ic_faucet, + action = onFaucetClick + ) + } + + MenuItem( + title = stringResource(R.string.explore_merchants_title), + subtitle = stringResource(R.string.explore_merchants_subtitle), + icon = R.drawable.ic_map, + action = onWhereToSpendClick + ) + + MenuItem( + title = stringResource(R.string.explore_atms_title), + subtitle = stringResource(R.string.explore_atms_subtitle), + icon = R.drawable.ic_atm, + action = onAtmsClick + ) + + if (state.showStaking) { + StakingMenuItem( + apy = state.apy, + onClick = onStakingClick + ) + } + } + } + + // CrowdNode withdrawal banner (below the card) + if (state.showWithdrawalBanner) { + CrowdNodeWithdrawalBanner(onWithdrawClick = onWithdrawClick) + } + } + } +} + +@Composable +private fun StakingMenuItem( + apy: Double, + onClick: () -> Unit +) { + if (apy == 0.0) { + MenuItem( + title = stringResource(R.string.staking_title), + subtitle = stringResource(R.string.explore_staking_subtitle), + icon = R.drawable.ic_deposit, + action = onClick + ) + } else { + // MenuItem does not support an inline APY pill, so render the row layout + // explicitly while reusing the same spacing/typography tokens. + Row( + modifier = Modifier + .fillMaxWidth() + .background(Color.Transparent, RoundedCornerShape(20.dp)) + .clickable(onClick = onClick) + .padding(horizontal = 10.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(20.dp) + ) { + Box(modifier = Modifier.size(26.dp)) { + Image( + painter = painterResource(id = R.drawable.ic_deposit), + contentDescription = null, + modifier = Modifier.size(30.dp) + ) + } + + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Text( + text = stringResource(R.string.staking_title), + style = MyTheme.Body2Medium, + color = MyTheme.Colors.textPrimary + ) + Text( + text = stringResource(R.string.explore_staking_subtitle), + style = MyTheme.Typography.BodyMedium, + color = MyTheme.Colors.textSecondary, + modifier = Modifier.fillMaxWidth() + ) + ApyBadge(apy = apy) + } + } + } +} + +@Composable +private fun ApyBadge(apy: Double) { + Row( + modifier = Modifier + .padding(top = 2.dp) + .background(MyTheme.Colors.green.copy(alpha = 0.1f), RoundedCornerShape(8.dp)) + .padding(horizontal = 6.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Image( + painter = painterResource(id = R.drawable.ic_circle_green_percent), + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + Text( + text = stringResource( + R.string.explore_staking_current_apy, + String.format("%.1f", apy) + ), + style = MyTheme.Typography.LabelMedium, + color = MyTheme.Colors.green + ) + } +} + +@Composable +private fun CrowdNodeWithdrawalBanner( + onWithdrawClick: () -> Unit +) { + Box( + modifier = Modifier + .fillMaxWidth() + .background(MyTheme.Colors.gray.copy(alpha = 0.1f), RoundedCornerShape(20.dp)) + .padding(16.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + Image( + painter = painterResource(id = R.drawable.ic_warning_triangle), + contentDescription = null, + modifier = Modifier.size(30.dp) + ) + + Column( + modifier = Modifier + .weight(1f) + .padding(top = 5.dp, end = 20.dp), + verticalArrangement = Arrangement.spacedBy(20.dp) + ) { + Column(verticalArrangement = Arrangement.spacedBy(1.dp)) { + Text( + text = stringResource(R.string.crowdnode_withdrawal_reminder_title), + style = MyTheme.Body2Medium, + color = MyTheme.Colors.textPrimary, + modifier = Modifier.fillMaxWidth() + ) + Text( + text = stringResource(R.string.crowdnode_withdrawal_reminder_message), + style = MyTheme.Body2Regular, + color = MyTheme.Colors.textSecondary, + modifier = Modifier.fillMaxWidth() + ) + } + + DashButton( + text = stringResource(R.string.crowdnode_withdraw_funds), + style = Style.FilledBlue, + size = Size.Small, + stretch = false, + onClick = onWithdrawClick + ) + } + } + } +} + +@Preview(showBackground = true, heightDp = 900) +@Composable +private fun ExploreScreenFullPreview() { + ExploreScreen( + state = ExploreScreenState( + showFaucet = true, + showStaking = true, + apy = 5.7, + showWithdrawalBanner = true + ), + onBackClick = {}, + onWhereToSpendClick = {}, + onAtmsClick = {}, + onStakingClick = {}, + onFaucetClick = {}, + onWithdrawClick = {} + ) +} + +@Preview(showBackground = true) +@Composable +private fun ExploreScreenMinimalPreview() { + ExploreScreen( + state = ExploreScreenState( + showFaucet = false, + showStaking = false, + apy = 0.0, + showWithdrawalBanner = false + ), + onBackClick = {}, + onWhereToSpendClick = {}, + onAtmsClick = {}, + onStakingClick = {}, + onFaucetClick = {}, + onWithdrawClick = {} + ) +} diff --git a/wallet/src/de/schildbach/wallet/ui/main/MainActivity.kt b/wallet/src/de/schildbach/wallet/ui/main/MainActivity.kt index dc76fc59ac..5e0825d1c1 100644 --- a/wallet/src/de/schildbach/wallet/ui/main/MainActivity.kt +++ b/wallet/src/de/schildbach/wallet/ui/main/MainActivity.kt @@ -48,6 +48,8 @@ import de.schildbach.wallet.ui.coinjoin.CoinJoinLevelViewModel import de.schildbach.wallet.ui.dashpay.* import de.schildbach.wallet.ui.invite.InviteHandler import de.schildbach.wallet.ui.invite.InviteSendContactRequestDialog +import de.schildbach.wallet.ui.staking.StakingActivity +import de.schildbach.wallet.ui.staking.createCrowdNodeWithdrawalReminderDialog import de.schildbach.wallet.ui.main.MainActivityExt.checkLowStorageAlert import de.schildbach.wallet.ui.main.MainActivityExt.checkTimeSkew import de.schildbach.wallet.ui.main.MainActivityExt.handleFirebaseAction @@ -125,6 +127,7 @@ class MainActivity : AbstractBindServiceActivity(), ActivityCompat.OnRequestPerm private var isRestoringBackup = false private var showBackupWalletDialog = false private var retryCreationIfInProgress = true + private var pendingCrowdNodeWithdrawalReminder = false var composeHostFrameLayout: ComposeHostFrameLayout? = null val requestPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { _ -> @@ -228,6 +231,15 @@ class MainActivity : AbstractBindServiceActivity(), ActivityCompat.OnRequestPerm } } } + viewModel.showCrowdNodeWithdrawalReminder.observe(this) { + if (lockScreenDisplayed) { + // Don't surface the reminder over the lock screen; defer until it's dismissed. + pendingCrowdNodeWithdrawalReminder = true + } else { + presentCrowdNodeWithdrawalReminder() + } + } + viewModel.sendContactRequestState.observe(this) { workInfoMap -> config.inviter?.also { initInvitationUserId -> if (!config.inviterContactRequestSentInfoShown) { @@ -533,6 +545,17 @@ class MainActivity : AbstractBindServiceActivity(), ActivityCompat.OnRequestPerm explainPushNotifications() } showStaleRatesToast() + + if (pendingCrowdNodeWithdrawalReminder) { + pendingCrowdNodeWithdrawalReminder = false + presentCrowdNodeWithdrawalReminder() + } + } + + private fun presentCrowdNodeWithdrawalReminder() { + createCrowdNodeWithdrawalReminderDialog { + startActivity(StakingActivity.createIntent(this, goToWithdraw = true)) + }.show(this) } override fun onLockScreenActivated() { diff --git a/wallet/src/de/schildbach/wallet/ui/main/MainViewModel.kt b/wallet/src/de/schildbach/wallet/ui/main/MainViewModel.kt index 5e5b2ff520..458eec033d 100644 --- a/wallet/src/de/schildbach/wallet/ui/main/MainViewModel.kt +++ b/wallet/src/de/schildbach/wallet/ui/main/MainViewModel.kt @@ -93,6 +93,8 @@ import org.dash.wallet.common.services.analytics.AnalyticsConstants import org.dash.wallet.common.services.analytics.AnalyticsService import org.dash.wallet.common.services.analytics.AnalyticsTimer import org.dash.wallet.common.transactions.TransactionWrapper +import org.dash.wallet.integrations.crowdnode.api.CrowdNodeApi +import org.dash.wallet.integrations.crowdnode.model.SignUpStatus import org.slf4j.LoggerFactory import java.util.Currency import java.util.Locale @@ -124,7 +126,8 @@ class MainViewModel @Inject constructor( dashPayContactRequestDao: DashPayContactRequestDao, private val coinJoinConfig: CoinJoinConfig, private val coinJoinService: CoinJoinService, - private val txDisplayCacheService: TxDisplayCacheService + private val txDisplayCacheService: TxDisplayCacheService, + private val crowdNodeApi: CrowdNodeApi ) : BaseContactsViewModel(blockchainIdentityDataDao, dashPayProfileDao, dashPayContactRequestDao) { var restoringBackup: Boolean = false @@ -241,6 +244,9 @@ class MainViewModel @Inject constructor( ) val showCreateUsernameEvent = SingleLiveEvent() + + // One-time-per-launch nudge to withdraw a remaining CrowdNode balance after sync. + val showCrowdNodeWithdrawalReminder = SingleLiveEvent() val sendContactRequestState = SendContactRequestOperation.allOperationsStatus(walletApplication) val seriousErrorLiveData = SeriousErrorLiveData(platformRepo) var processingSeriousError = false @@ -274,6 +280,27 @@ class MainViewModel @Inject constructor( .catch { e -> log.error("blockchain state flow error", e) } .launchIn(viewModelScope) + // Once per launch, after sync, remind the user to withdraw any remaining CrowdNode balance. + combine( + blockchainStateProvider.observeState().filterNotNull(), + crowdNodeApi.signUpStatus, + crowdNodeApi.balance + ) { state, signUpStatus, balance -> + Triple(state.isSynced(), signUpStatus, balance.data) + } + .onEach { (isSynced, signUpStatus, balance) -> + val hasAccount = signUpStatus != SignUpStatus.NotStarted + val hasBalance = balance?.isPositive == true + val alreadyShown: Boolean = savedStateHandle[CROWDNODE_REMINDER_SHOWN_KEY] ?: false + + if (isSynced && hasAccount && hasBalance && !alreadyShown) { + savedStateHandle[CROWDNODE_REMINDER_SHOWN_KEY] = true + showCrowdNodeWithdrawalReminder.postCall() + } + } + .catch { e -> log.error("crowdnode withdrawal reminder flow error", e) } + .launchIn(viewModelScope) + // we need the total wallet balance for mixing progress, walletData.observeTotalBalance() .onEach { @@ -650,6 +677,7 @@ class MainViewModel @Inject constructor( companion object { private const val DIRECTION_KEY = "tx_direction" + private const val CROWDNODE_REMINDER_SHOWN_KEY = "crowdnode_withdrawal_reminder_shown" private const val TIME_SKEW_TOLERANCE = 3600000L // 1 hour private val log = LoggerFactory.getLogger(MainViewModel::class.java) diff --git a/wallet/src/de/schildbach/wallet/ui/staking/CrowdNodeWithdrawalReminderDialog.kt b/wallet/src/de/schildbach/wallet/ui/staking/CrowdNodeWithdrawalReminderDialog.kt new file mode 100644 index 0000000000..1d8a67a1f2 --- /dev/null +++ b/wallet/src/de/schildbach/wallet/ui/staking/CrowdNodeWithdrawalReminderDialog.kt @@ -0,0 +1,137 @@ +/* + * Copyright (c) 2025 Dash Core Group + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package de.schildbach.wallet.ui.staking + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +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 de.schildbach.wallet.ui.compose_views.ComposeBottomSheet +import de.schildbach.wallet_test.R +import org.dash.wallet.common.ui.components.ButtonGroupOrientation +import org.dash.wallet.common.ui.components.FeatureTopText +import org.dash.wallet.common.ui.components.MyTheme +import org.dash.wallet.common.ui.components.SheetButton +import org.dash.wallet.common.ui.components.SheetButtonGroup +import org.dash.wallet.common.ui.components.Style + +/** + * Creates a dismissible bottom sheet shown on MainActivity after sync when the user still has a + * balance on CrowdNode, nudging them to withdraw it. + * + * Figma Node ID: 2464:18269 (Sheet CrowdNode. Withdraw funds) + * + * @param onWithdraw invoked when the user taps "Withdraw funds" (before the sheet dismisses) + * @return ComposeBottomSheet instance ready to be shown + */ +fun createCrowdNodeWithdrawalReminderDialog(onWithdraw: () -> Unit): ComposeBottomSheet { + return ComposeBottomSheet( + backgroundStyle = R.style.SecondaryBackground, + forceExpand = false, + content = { dialog -> + CrowdNodeWithdrawalReminderContent( + onWithdraw = { + onWithdraw() + dialog.dismiss() + }, + onClose = { dialog.dismiss() } + ) + } + ) +} + +@Composable +private fun CrowdNodeWithdrawalReminderContent( + onWithdraw: () -> Unit, + onClose: () -> Unit +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(top = 60.dp), // Space for drag indicator and close button + horizontalAlignment = Alignment.CenterHorizontally + ) { + // Composite icon: CrowdNode logo on a light circle with a warning-triangle badge + Box( + modifier = Modifier.padding(top = 20.dp, bottom = 10.dp), + contentAlignment = Alignment.Center + ) { + Box( + modifier = Modifier + .size(90.dp) + .background(MyTheme.Colors.backgroundPrimary, CircleShape), + contentAlignment = Alignment.Center + ) { + Image( + painter = painterResource(R.drawable.ic_crowdnode_logo), + contentDescription = null, + modifier = Modifier.size(44.dp) + ) + } + Image( + painter = painterResource(R.drawable.ic_warning_triangle), + contentDescription = null, + modifier = Modifier + .align(Alignment.BottomEnd) + .offset(x = (-12).dp, y = (-8).dp) + .size(22.dp) + ) + } + + FeatureTopText( + heading = stringResource(R.string.crowdnode_withdrawal_reminder_title), + text = stringResource(R.string.crowdnode_withdrawal_reminder_message), + showText = true, + showButton = false, + modifier = Modifier.padding(top = 20.dp, bottom = 32.dp) + ) + + SheetButtonGroup( + primaryButton = SheetButton( + text = stringResource(R.string.crowdnode_withdraw_funds), + style = Style.FilledBlue, + onClick = onWithdraw + ), + secondaryButton = SheetButton( + text = stringResource(R.string.button_close), + style = Style.TintedGray, + onClick = onClose + ), + orientation = ButtonGroupOrientation.Vertical, + spacing = 16.dp + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun CrowdNodeWithdrawalReminderContentPreview() { + CrowdNodeWithdrawalReminderContent(onWithdraw = {}, onClose = {}) +} diff --git a/wallet/src/de/schildbach/wallet/ui/staking/StakingActivity.kt b/wallet/src/de/schildbach/wallet/ui/staking/StakingActivity.kt index da90c0c80e..25ebac76ff 100644 --- a/wallet/src/de/schildbach/wallet/ui/staking/StakingActivity.kt +++ b/wallet/src/de/schildbach/wallet/ui/staking/StakingActivity.kt @@ -17,9 +17,11 @@ package de.schildbach.wallet.ui.staking +import android.content.Context import android.content.Intent import android.os.Bundle import androidx.activity.viewModels +import androidx.core.os.bundleOf import androidx.core.view.isVisible import androidx.lifecycle.lifecycleScope import androidx.navigation.NavController @@ -47,6 +49,16 @@ import javax.inject.Inject class StakingActivity : LockScreenActivity() { companion object { private val log = LoggerFactory.getLogger(StakingActivity::class.java) + + // When set, navigate straight to the CrowdNode withdrawal screen (for existing accounts) + const val EXTRA_GO_TO_WITHDRAW = "extra_go_to_withdraw" + private const val WITHDRAW_ARG = "withdraw" + + fun createIntent(context: Context, goToWithdraw: Boolean = false): Intent { + return Intent(context, StakingActivity::class.java).apply { + putExtra(EXTRA_GO_TO_WITHDRAW, goToWithdraw) + } + } } private val viewModel: CrowdNodeViewModel by viewModels() @@ -62,6 +74,7 @@ class StakingActivity : LockScreenActivity() { binding = ActivityStakingBinding.inflate(layoutInflater) lifecycleScope.launch { navController = setNavigationGraph() + maybeNavigateToWithdrawal() viewModel.observeOnlineAccountStatus().observe(this@StakingActivity, ::handleOnlineAccountStatus) } @@ -74,6 +87,19 @@ class StakingActivity : LockScreenActivity() { setContentView(binding.root) } + private fun maybeNavigateToWithdrawal() { + if (!intent.getBooleanExtra(EXTRA_GO_TO_WITHDRAW, false)) { + return + } + + // Only existing accounts (start destination = Portal) can go straight to withdrawal. + // Navigate on top of Portal so Back returns to it. + val status = viewModel.signUpStatus + if (status == SignUpStatus.Finished || status == SignUpStatus.LinkedOnline) { + navController.navigate(R.id.transferFragment, bundleOf(WITHDRAW_ARG to true)) + } + } + private fun handleNavigationRequest(request: NavigationRequest) { when (request) { NavigationRequest.BackupPassphrase -> checkPinAndBackupPassphrase() From ced893561ef3123b18ce9b26a08045513a6620c7 Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Thu, 18 Jun 2026 10:28:01 -0700 Subject: [PATCH 2/3] fix: minor issues --- .../crowdnode/api/CrowdNodeApi.kt | 1 + .../wallet/ui/explore/ExploreScreen.kt | 112 +++++++++--------- 2 files changed, 57 insertions(+), 56 deletions(-) diff --git a/integrations/crowdnode/src/main/java/org/dash/wallet/integrations/crowdnode/api/CrowdNodeApi.kt b/integrations/crowdnode/src/main/java/org/dash/wallet/integrations/crowdnode/api/CrowdNodeApi.kt index 33838a5e3a..aff40a2296 100644 --- a/integrations/crowdnode/src/main/java/org/dash/wallet/integrations/crowdnode/api/CrowdNodeApi.kt +++ b/integrations/crowdnode/src/main/java/org/dash/wallet/integrations/crowdnode/api/CrowdNodeApi.kt @@ -122,6 +122,7 @@ class CrowdNodeApiAggregator @Inject constructor( Executors.newSingleThreadExecutor().asCoroutineDispatcher() ) private var isOnlineStatusRestored: Boolean = false + // Serializes restoreStatus() so concurrent callers (e.g. the init call and the // blockchain-sync observer) don't both scan the whole wallet and contend on the // wallet keychain lock at the same time. diff --git a/wallet/src/de/schildbach/wallet/ui/explore/ExploreScreen.kt b/wallet/src/de/schildbach/wallet/ui/explore/ExploreScreen.kt index be63ee8980..2f2682bd08 100644 --- a/wallet/src/de/schildbach/wallet/ui/explore/ExploreScreen.kt +++ b/wallet/src/de/schildbach/wallet/ui/explore/ExploreScreen.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025 Dash Core Group + * Copyright (c) 2026 Dash Core Group * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -81,69 +81,69 @@ fun ExploreScreen( .padding(bottom = 20.dp), verticalArrangement = Arrangement.spacedBy(20.dp) ) { - // Title + subtitle (TopIntro pattern, no extra horizontal padding since we already padded) - Column( - modifier = Modifier - .fillMaxWidth() - .padding(end = 40.dp), - verticalArrangement = Arrangement.spacedBy(6.dp) - ) { - Text( - text = stringResource(R.string.explore_dash), - style = MyTheme.Typography.HeadlineSmallBold, - color = MyTheme.Colors.textPrimary, - modifier = Modifier.fillMaxWidth() - ) - Text( - text = stringResource(R.string.explore_subtitle), - style = MyTheme.Body2Regular, - color = MyTheme.Colors.textSecondary, - modifier = Modifier.fillMaxWidth() - ) - } - - // Menu card with the list of destinations - Box( - modifier = Modifier - .fillMaxWidth() - .background(MyTheme.Colors.backgroundSecondary, RoundedCornerShape(20.dp)) - .padding(6.dp) - ) { + // Title + subtitle (TopIntro pattern, no extra horizontal padding since we already padded) Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(2.dp) + modifier = Modifier + .fillMaxWidth() + .padding(end = 40.dp), + verticalArrangement = Arrangement.spacedBy(6.dp) ) { - if (state.showFaucet) { - MenuItem( - title = stringResource(R.string.explore_get_test_dash), - subtitle = stringResource(R.string.explore_test_dash_text_1), - icon = R.drawable.ic_faucet, - action = onFaucetClick - ) - } - - MenuItem( - title = stringResource(R.string.explore_merchants_title), - subtitle = stringResource(R.string.explore_merchants_subtitle), - icon = R.drawable.ic_map, - action = onWhereToSpendClick + Text( + text = stringResource(R.string.explore_dash), + style = MyTheme.Typography.HeadlineSmallBold, + color = MyTheme.Colors.textPrimary, + modifier = Modifier.fillMaxWidth() ) - - MenuItem( - title = stringResource(R.string.explore_atms_title), - subtitle = stringResource(R.string.explore_atms_subtitle), - icon = R.drawable.ic_atm, - action = onAtmsClick + Text( + text = stringResource(R.string.explore_subtitle), + style = MyTheme.Body2Regular, + color = MyTheme.Colors.textSecondary, + modifier = Modifier.fillMaxWidth() ) + } + + // Menu card with the list of destinations + Box( + modifier = Modifier + .fillMaxWidth() + .background(MyTheme.Colors.backgroundSecondary, RoundedCornerShape(20.dp)) + .padding(6.dp) + ) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + if (state.showFaucet) { + MenuItem( + title = stringResource(R.string.explore_get_test_dash), + subtitle = stringResource(R.string.explore_test_dash_text_1), + icon = R.drawable.ic_faucet, + action = onFaucetClick + ) + } + + MenuItem( + title = stringResource(R.string.explore_merchants_title), + subtitle = stringResource(R.string.explore_merchants_subtitle), + icon = R.drawable.ic_map, + action = onWhereToSpendClick + ) - if (state.showStaking) { - StakingMenuItem( - apy = state.apy, - onClick = onStakingClick + MenuItem( + title = stringResource(R.string.explore_atms_title), + subtitle = stringResource(R.string.explore_atms_subtitle), + icon = R.drawable.ic_atm, + action = onAtmsClick ) + + if (state.showStaking) { + StakingMenuItem( + apy = state.apy, + onClick = onStakingClick + ) + } } } - } // CrowdNode withdrawal banner (below the card) if (state.showWithdrawalBanner) { From 7e583635626225e9ce1112e20cea7affd59efebf Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Sat, 20 Jun 2026 16:31:36 -0700 Subject: [PATCH 3/3] fix: update UI based on designs --- .../wallet/ui/explore/ExploreScreen.kt | 69 ++++++++----------- .../CrowdNodeWithdrawalReminderDialog.kt | 7 +- 2 files changed, 33 insertions(+), 43 deletions(-) diff --git a/wallet/src/de/schildbach/wallet/ui/explore/ExploreScreen.kt b/wallet/src/de/schildbach/wallet/ui/explore/ExploreScreen.kt index 2f2682bd08..b3a905d6f5 100644 --- a/wallet/src/de/schildbach/wallet/ui/explore/ExploreScreen.kt +++ b/wallet/src/de/schildbach/wallet/ui/explore/ExploreScreen.kt @@ -39,6 +39,7 @@ 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 de.schildbach.wallet.ui.compose_views.Shadows.softShadow import de.schildbach.wallet_test.R import org.dash.wallet.common.ui.components.DashButton import org.dash.wallet.common.ui.components.MenuItem @@ -46,6 +47,7 @@ import org.dash.wallet.common.ui.components.MyTheme import org.dash.wallet.common.ui.components.NavBarBack import org.dash.wallet.common.ui.components.Size import org.dash.wallet.common.ui.components.Style +import java.util.Locale data class ExploreScreenState( val showFaucet: Boolean, // testnet only @@ -85,12 +87,13 @@ fun ExploreScreen( Column( modifier = Modifier .fillMaxWidth() - .padding(end = 40.dp), - verticalArrangement = Arrangement.spacedBy(6.dp) + .padding(end = 40.dp) + .padding(bottom = 10.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) ) { Text( text = stringResource(R.string.explore_dash), - style = MyTheme.Typography.HeadlineSmallBold, + style = MyTheme.Typography.HeadlineMediumBold, color = MyTheme.Colors.textPrimary, modifier = Modifier.fillMaxWidth() ) @@ -106,7 +109,8 @@ fun ExploreScreen( Box( modifier = Modifier .fillMaxWidth() - .background(MyTheme.Colors.backgroundSecondary, RoundedCornerShape(20.dp)) + .softShadow(16.dp) + .background(MyTheme.Colors.backgroundSecondary, RoundedCornerShape(16.dp)) .padding(6.dp) ) { Column( @@ -200,38 +204,21 @@ private fun StakingMenuItem( color = MyTheme.Colors.textSecondary, modifier = Modifier.fillMaxWidth() ) - ApyBadge(apy = apy) + // APY shown as plain blue inline text (per design) + Text( + text = stringResource( + R.string.explore_staking_current_apy, + String.format(Locale.getDefault(), "%.1f", apy) + ), + style = MyTheme.Body2Regular, + color = MyTheme.Colors.dashBlue, + modifier = Modifier.padding(top = 2.dp) + ) } } } } -@Composable -private fun ApyBadge(apy: Double) { - Row( - modifier = Modifier - .padding(top = 2.dp) - .background(MyTheme.Colors.green.copy(alpha = 0.1f), RoundedCornerShape(8.dp)) - .padding(horizontal = 6.dp, vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp) - ) { - Image( - painter = painterResource(id = R.drawable.ic_circle_green_percent), - contentDescription = null, - modifier = Modifier.size(16.dp) - ) - Text( - text = stringResource( - R.string.explore_staking_current_apy, - String.format("%.1f", apy) - ), - style = MyTheme.Typography.LabelMedium, - color = MyTheme.Colors.green - ) - } -} - @Composable private fun CrowdNodeWithdrawalBanner( onWithdrawClick: () -> Unit @@ -255,13 +242,13 @@ private fun CrowdNodeWithdrawalBanner( Column( modifier = Modifier .weight(1f) - .padding(top = 5.dp, end = 20.dp), + .padding(top = 2.dp, end = 20.dp), verticalArrangement = Arrangement.spacedBy(20.dp) ) { Column(verticalArrangement = Arrangement.spacedBy(1.dp)) { Text( text = stringResource(R.string.crowdnode_withdrawal_reminder_title), - style = MyTheme.Body2Medium, + style = MyTheme.Typography.TitleMediumMedium, color = MyTheme.Colors.textPrimary, modifier = Modifier.fillMaxWidth() ) @@ -273,13 +260,15 @@ private fun CrowdNodeWithdrawalBanner( ) } - DashButton( - text = stringResource(R.string.crowdnode_withdraw_funds), - style = Style.FilledBlue, - size = Size.Small, - stretch = false, - onClick = onWithdrawClick - ) + Box(modifier = Modifier.padding(bottom = 14.dp)) { + DashButton( + text = stringResource(R.string.crowdnode_withdraw_funds), + style = Style.FilledBlue, + size = Size.Small, + stretch = false, + onClick = onWithdrawClick + ) + } } } } diff --git a/wallet/src/de/schildbach/wallet/ui/staking/CrowdNodeWithdrawalReminderDialog.kt b/wallet/src/de/schildbach/wallet/ui/staking/CrowdNodeWithdrawalReminderDialog.kt index 1d8a67a1f2..5b89639c30 100644 --- a/wallet/src/de/schildbach/wallet/ui/staking/CrowdNodeWithdrawalReminderDialog.kt +++ b/wallet/src/de/schildbach/wallet/ui/staking/CrowdNodeWithdrawalReminderDialog.kt @@ -22,7 +22,6 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape @@ -95,19 +94,20 @@ private fun CrowdNodeWithdrawalReminderContent( modifier = Modifier.size(44.dp) ) } + // Warning badge sits flush in the bottom-right corner of the 90dp circle (Figma 36053:17544) Image( painter = painterResource(R.drawable.ic_warning_triangle), contentDescription = null, modifier = Modifier .align(Alignment.BottomEnd) - .offset(x = (-12).dp, y = (-8).dp) - .size(22.dp) + .size(34.dp) ) } FeatureTopText( heading = stringResource(R.string.crowdnode_withdrawal_reminder_title), text = stringResource(R.string.crowdnode_withdrawal_reminder_message), + textStyle = MyTheme.Typography.HeadlineMediumBold, showText = true, showButton = false, modifier = Modifier.padding(top = 20.dp, bottom = 32.dp) @@ -125,6 +125,7 @@ private fun CrowdNodeWithdrawalReminderContent( onClick = onClose ), orientation = ButtonGroupOrientation.Vertical, + horizontalPadding = 60.dp, spacing = 16.dp ) }