Skip to content

Commit 751422d

Browse files
committed
feat: present contextual modal for chat/send for the user to purchase a currency post adding money
Chat/Send both require a launchpad/community currency (currently since GiveUsdf isn't enabled yet) so Adding Money doesn't unblock those flows. Now once they've added money (USDC -> USDF) we prompt them to discover and purchase a currency with their new funds. Signed-off-by: Brandon McAnsh <git@bmcreations.dev>
1 parent 049e4ee commit 751422d

8 files changed

Lines changed: 126 additions & 59 deletions

File tree

apps/flipcash/core/src/main/res/values/strings.xml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,8 @@
255255
<string name="title_tapAboveToAddCashToWallet">Tap above to Add Cash to your wallet</string>
256256
<string name="title_tapBelowToAddCashWallet">You don\'t have any cash yet.\nTap below to add cash to your wallet</string>
257257
<string name="title_noBalanceYet">No Balance Yet</string>
258+
<string name="title_noCommunityCurrenciesYet">No Community Currencies Yet</string>
259+
<string name="description_noCommunityCurrenciesYet">Discover and buy a currency, or create your own</string>
258260
<string name="title_insufficientBalance">Insufficient Balance</string>
259261
<string name="description_insufficientBalanceToCreate">Add more money to create a currency</string>
260262
<string name="description_insufficientBalanceToUse">Add more money, or enter a smaller amount</string>

apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import com.flipcash.app.core.AppRoute
1313
import com.flipcash.app.core.chat.ChatIdentifier
1414
import com.flipcash.app.core.chat.ChatSendResult
1515
import com.flipcash.app.core.chat.ChatStep
16+
import com.flipcash.app.core.extensions.openAsSheet
1617
import com.flipcash.app.messenger.internal.ChatViewModel
1718
import com.flipcash.app.messenger.internal.screens.MessengerScreen
1819
import com.getcode.navigation.annotatedEntry
@@ -26,6 +27,7 @@ import com.getcode.navigation.results.NavResultOrCanceled
2627
import com.getcode.navigation.results.NavResultStateRegistry
2728
import com.getcode.navigation.results.navigateForResult
2829
import com.getcode.navigation.results.resultBackNavigator
30+
import com.getcode.navigation.scenes.LocalSheetNavigator
2931
import kotlinx.coroutines.flow.filterIsInstance
3032
import kotlinx.coroutines.flow.map
3133

@@ -59,8 +61,10 @@ private fun chatEntryProvider(
5961
@Composable
6062
private fun FlowConversationScreen(identifier: ChatIdentifier) {
6163
val viewModel = flowSharedViewModel<ChatViewModel>()
62-
val flowNavigator = rememberFlowNavigator<ChatStep, Parcelable>()
6364
val navigator = LocalCodeNavigator.current
65+
// The sheet-owning (root) navigator — the one whose back stack holds this chat's
66+
// Main.Sheet and whose pendingSheetDismiss the dismiss animation observes.
67+
val sheetNavigator = LocalSheetNavigator.current
6468

6569
LaunchedEffect(viewModel, identifier) {
6670
viewModel.dispatchEvent(ChatViewModel.Event.OnChatOpened(identifier))
@@ -81,9 +85,15 @@ private fun FlowConversationScreen(identifier: ChatIdentifier) {
8185
LaunchedEffect(viewModel) {
8286
viewModel.eventFlow
8387
.filterIsInstance<ChatViewModel.Event.OpenScreen>()
84-
.map { it.route }
85-
.collect { route ->
86-
flowNavigator.navigate(route)
88+
.collect { (route, asSheet) ->
89+
if (asSheet) {
90+
// Dismiss this chat sheet and open [route] as a fresh sheet.
91+
// openAsSheet on the sheet-owning navigator animates the current
92+
// sheet closed (via pendingSheetDismiss) before opening the new one.
93+
sheetNavigator?.openAsSheet(route)
94+
} else {
95+
navigator.navigate(route)
96+
}
8797
}
8898
}
8999

apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt

Lines changed: 49 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,7 @@ internal class ChatViewModel @Inject constructor(
143143

144144
data class NavigateToAmountEntry(val contact: DeviceContact) : Event
145145
data object PresentDepositOptions : Event
146-
data class OpenScreen(val route: AppRoute): Event
146+
data class OpenScreen(val route: AppRoute, val asSheet: Boolean = false): Event
147147
data object OnConfirmRequested : Event
148148
data class OnSendRequested(
149149
val amount: Fiat,
@@ -519,34 +519,13 @@ internal class ChatViewModel @Inject constructor(
519519
eventFlow.filterIsInstance<Event.OnSendCash>()
520520
.mapNotNull { stateFlow.value.chattingWith }
521521
.onEach { contact ->
522+
val addMoney = featureFlags.get(FeatureFlag.AddMoneyUX)
522523
if (!tokenCoordinator.hasGiveableBalance()) {
523-
val addMoney = featureFlags.get(FeatureFlag.AddMoneyUX)
524-
val message = if (addMoney) {
525-
resources.getString(R.string.description_noBalanceYetToSend)
524+
if (!tokenCoordinator.hasBalance()) {
525+
presentAddMoney(addMoney)
526526
} else {
527-
resources.getString(R.string.description_noBalanceYetDiscover)
527+
presentDiscoverCurrencies()
528528
}
529-
val cta = if (addMoney) {
530-
resources.getString(R.string.action_addMoney)
531-
} else {
532-
resources.getString(R.string.action_discover)
533-
}
534-
BottomBarManager.showInfo(
535-
title = resources.getString(R.string.title_noBalanceYet),
536-
message = message,
537-
actions = listOf(
538-
BottomBarAction(
539-
text = cta
540-
) {
541-
if (addMoney) {
542-
dispatchEvent(Event.PresentDepositOptions)
543-
} else {
544-
dispatchEvent(Event.OpenScreen(AppRoute.Token.Discovery))
545-
}
546-
},
547-
),
548-
showCancel = true,
549-
)
550529
return@onEach
551530
}
552531
amountDelegate.reset()
@@ -706,6 +685,50 @@ internal class ChatViewModel @Inject constructor(
706685
}
707686
}
708687

688+
private fun presentAddMoney(addMoneyEnabled: Boolean) {
689+
val message = if (addMoneyEnabled) {
690+
resources.getString(R.string.description_noBalanceYetToSend)
691+
} else {
692+
resources.getString(R.string.description_noBalanceYetDiscover)
693+
}
694+
val cta = if (addMoneyEnabled) {
695+
resources.getString(R.string.action_addMoney)
696+
} else {
697+
resources.getString(R.string.action_discover)
698+
}
699+
BottomBarManager.showInfo(
700+
title = resources.getString(R.string.title_noBalanceYet),
701+
message = message,
702+
actions = listOf(
703+
BottomBarAction(
704+
text = cta
705+
) {
706+
if (addMoneyEnabled) {
707+
dispatchEvent(Event.PresentDepositOptions)
708+
} else {
709+
dispatchEvent(Event.OpenScreen(AppRoute.Token.Discovery))
710+
}
711+
},
712+
),
713+
showCancel = true,
714+
)
715+
}
716+
717+
private fun presentDiscoverCurrencies() {
718+
BottomBarManager.showInfo(
719+
title = resources.getString(R.string.title_noCommunityCurrenciesYet),
720+
message = resources.getString(R.string.description_noCommunityCurrenciesYet),
721+
actions = listOf(
722+
BottomBarAction(
723+
text = resources.getString(R.string.action_discoverCurrencies)
724+
) {
725+
dispatchEvent(Event.OpenScreen(AppRoute.Token.Discovery, asSheet = true))
726+
},
727+
),
728+
showCancel = true,
729+
)
730+
}
731+
709732
companion object {
710733
val updateStateForEvent: (Event) -> ((State) -> State) = { event ->
711734
when (event) {

apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/Scanner.kt

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import android.annotation.SuppressLint
44
import androidx.compose.runtime.Composable
55
import androidx.compose.runtime.DisposableEffect
66
import androidx.compose.runtime.LaunchedEffect
7-
import androidx.lifecycle.compose.collectAsStateWithLifecycle
87
import androidx.compose.runtime.getValue
98
import androidx.compose.runtime.mutableFloatStateOf
109
import androidx.compose.runtime.mutableStateOf
@@ -13,14 +12,15 @@ import androidx.compose.runtime.setValue
1312
import androidx.compose.ui.platform.LocalContext
1413
import androidx.compose.ui.platform.LocalFocusManager
1514
import androidx.lifecycle.Lifecycle
15+
import androidx.lifecycle.compose.collectAsStateWithLifecycle
1616
import com.flipcash.app.analytics.rememberAnalytics
17-
import com.flipcash.app.core.AppRoute
17+
import com.flipcash.app.core.extensions.navigateAll
18+
import com.flipcash.app.core.extensions.openAsSheet
19+
import com.flipcash.app.core.navigation.DeeplinkType
1820
import com.flipcash.app.router.LocalRouter
1921
import com.flipcash.app.scanner.internal.bills.BillContainer
2022
import com.flipcash.app.session.LocalSessionController
21-
import com.flipcash.features.scanner.R
2223
import com.getcode.libs.code.detection.CodeScanResult
23-
import com.getcode.manager.BottomBarManager
2424
import com.getcode.navigation.core.LocalCodeNavigator
2525
import com.getcode.ui.biometrics.LocalBiometricsState
2626
import com.getcode.ui.components.OnLifecycleEvent
@@ -32,10 +32,6 @@ import com.getcode.utils.ErrorUtils
3232
import com.kik.kikx.kikcodes.implementation.KikCodeResult
3333
import dev.theolm.rinku.DeepLink
3434
import timber.log.Timber
35-
import com.flipcash.app.core.extensions.navigateAll
36-
import com.flipcash.app.core.extensions.openAsSheet
37-
import com.flipcash.app.core.navigation.DeeplinkType
38-
import com.getcode.manager.BottomBarAction
3935

4036
@Composable
4137
internal fun Scanner() {
@@ -86,12 +82,15 @@ internal fun Scanner() {
8682
when (it) {
8783
ScannerDecorItem.Give -> {
8884
// only allow navigation to give when there is something to give
85+
// Zero balance -> prompt to add money; otherwise the user has funds
86+
// but nothing giveable -> prompt to discover a currency.
87+
// presentDepositOptions picks the right prompt based on balance.
8988
if (!state.hasGiveableBalance) {
9089
session.presentDepositOptions { route ->
9190
navigator.openAsSheet(route)
9291
}
9392

94-
return@BillContainer
93+
return@BillContainer
9594
}
9695
}
9796
else -> Unit

apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/SessionController.kt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,11 @@ interface CashLinkOperations {
3434
}
3535

3636
interface DepositOperations {
37+
/**
38+
* Presents the appropriate "you can't give yet" prompt based on the user's balance:
39+
* an add-money prompt when the wallet is empty, or a discover-currencies prompt when
40+
* the user has funds (e.g. reserves) but nothing giveable.
41+
*/
3742
fun presentDepositOptions(onRoute: ((AppRoute) -> Unit)? = null)
3843
}
3944

@@ -46,6 +51,7 @@ interface SessionController : BillOperations, CodeScanOperations, CashLinkOperat
4651
data class SessionState(
4752
val vibrateOnScan: Boolean = false,
4853
val hasGiveableBalance: Boolean = false,
54+
val hasBalance: Boolean = false,
4955
val logScanTimes: Boolean = false,
5056
val showNetworkOffline: Boolean = false,
5157
val autoStartCamera: Boolean? = true,

apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/RealSessionController.kt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,12 @@ class RealSessionController @Inject constructor(
222222
.onEach { hasBalance -> stateHolder.update { it.copy(hasGiveableBalance = hasBalance) } }
223223
.launchIn(scope)
224224

225+
tokenCoordinator.tokenBalances
226+
.map { tokenCoordinator.hasBalance() }
227+
.distinctUntilChanged()
228+
.onEach { hasBalance -> stateHolder.update { it.copy(hasBalance = hasBalance) } }
229+
.launchIn(scope)
230+
225231
tokenCoordinator.tokens
226232
.onEach { tokens ->
227233
stateHolder.update { it.copy(tokens = tokens) }

apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/DepositDelegate.kt

Lines changed: 34 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -51,34 +51,35 @@ class DepositDelegate @Inject constructor(
5151
}
5252

5353
override fun presentDepositOptions(onRoute: ((AppRoute) -> Unit)?) {
54-
val depositFirstUx = stateHolder.current.addMoneyUx
54+
// Prompt to add money only when the wallet is empty and add-money is available.
55+
// Otherwise the user has funds (e.g. reserves) but nothing giveable — or can't
56+
// add money at all — so steer them to discover/buy a currency.
57+
val addMoneyEnabled = stateHolder.current.addMoneyUx
58+
val hasBalance = stateHolder.current.hasBalance
5559

56-
val message = if (depositFirstUx) {
57-
resources.getString(R.string.description_noBalanceYetToGive)
60+
if (!hasBalance) {
61+
presentAddMoney(addMoneyEnabled, onRoute)
5862
} else {
59-
resources.getString(R.string.description_noBalanceYetDiscover)
60-
}
61-
val cta = if (depositFirstUx) {
62-
resources.getString(R.string.action_addMoney)
63-
} else {
64-
resources.getString(R.string.action_discoverCurrencies)
63+
presentDiscoverCurrencies(onRoute)
6564
}
65+
}
6666

67+
private fun presentAddMoney(addMoneyEnabled: Boolean, onRoute: ((AppRoute) -> Unit)?) {
6768
BottomBarManager.showInfo(
6869
title = resources.getString(R.string.title_noBalanceYet),
69-
message = message,
70+
message = if (addMoneyEnabled) {
71+
resources.getString(R.string.description_noBalanceYetToGive)
72+
} else {
73+
resources.getString(R.string.description_noBalanceYetDiscover)
74+
},
7075
actions = listOf(
7176
BottomBarAction(
72-
text = cta
77+
text = resources.getString(R.string.action_addMoney)
7378
) {
7479
scope.launch {
75-
if (depositFirstUx) {
76-
val destination = purchaseMethodController.presentDepositOptions(popToRoot = true)
77-
if (destination != null) {
78-
onRoute?.invoke(destination)
79-
}
80-
} else {
81-
onRoute?.invoke(AppRoute.Token.Discovery)
80+
val destination = purchaseMethodController.presentDepositOptions(popToRoot = true)
81+
if (destination != null) {
82+
onRoute?.invoke(destination)
8283
}
8384
}
8485
},
@@ -87,6 +88,21 @@ class DepositDelegate @Inject constructor(
8788
)
8889
}
8990

91+
private fun presentDiscoverCurrencies(onRoute: ((AppRoute) -> Unit)?) {
92+
BottomBarManager.showInfo(
93+
title = resources.getString(R.string.title_noCommunityCurrenciesYet),
94+
message = resources.getString(R.string.description_noCommunityCurrenciesYet),
95+
actions = listOf(
96+
BottomBarAction(
97+
text = resources.getString(R.string.action_discoverCurrencies)
98+
) {
99+
onRoute?.invoke(AppRoute.Token.Discovery)
100+
},
101+
),
102+
showCancel = true,
103+
)
104+
}
105+
90106
fun sweepIfNeeded() {
91107
val owner = userManager.accountCluster ?: return
92108
if (userManager.authState.canAccessAuthenticatedApis) {

apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenCoordinator.kt

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -193,16 +193,21 @@ class TokenCoordinator @Inject constructor(
193193

194194
// region Public API — Balances
195195

196+
/** Can I hand money to a person right now? */
196197
suspend fun hasGiveableBalance(): Boolean {
197198
// USDF is only giveable when the GiveUsdf flag is on; otherwise a USDF-only
198199
// balance must not count as giveable.
199200
val canGiveUsdf = featureFlags.get(FeatureFlag.GiveUsdf)
200-
return _state.value.balances
201-
.filterKeys { canGiveUsdf || it != Mint.usdf }
201+
val state = _state.value
202+
return state.balances.filterKeys { canGiveUsdf || it != Mint.usdf }
202203
.values
203204
.any { it.hasDisplayableValue }
204205
}
205206

207+
/** Do I have any balance at all, including reserves? */
208+
suspend fun hasBalance(): Boolean =
209+
_state.value.balances.values.any { it.hasDisplayableValue }
210+
206211
fun balanceForToken(token: Token): Fiat = _state.value.balances[token.address] ?: Fiat.Zero
207212

208213
fun balanceForToken(tokenAddress: Mint): Flow<Fiat> =

0 commit comments

Comments
 (0)