From 0b80b7c5a1c2dd8d48fe9f1a1cf14834ebb33c45 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 15 Jun 2026 13:34:17 -0400 Subject: [PATCH 1/5] feat(flags): move beta flags to advanced features with track-based visibility - Add FeatureTrack enum and minTrack property to FeatureFlag for controlling flag visibility tiers (Production visible to all, Internal requires unlock) - Move Beta Flags from hidden staff-only menu item to always-visible sub-item under Advanced Features with Beta indicator - PhoneNumberSend set to Production track (always visible) - All other flags, Home Screen, and Developer sections require 7-tap beta override to appear Signed-off-by: Brandon McAnsh --- .../internal/AdvancedFeatureMenuItems.kt | 16 ++++++-- .../AdvancedFeaturesScreenViewModel.kt | 1 + .../app/lab/internal/LabsScreenContent.kt | 38 ++++++++++++------- .../app/lab/internal/LabsScreenViewModel.kt | 4 ++ .../flipcash/app/menu/internal/MenuItems.kt | 12 ------ .../app/menu/internal/MenuScreenViewModel.kt | 1 - .../flipcash/app/featureflags/FeatureFlag.kt | 11 ++++++ .../kotlin/com/flipcash/app/menu/MenuItem.kt | 2 + .../kotlin/com/flipcash/app/menu/MenuList.kt | 2 +- 9 files changed, 55 insertions(+), 32 deletions(-) diff --git a/apps/flipcash/features/advanced/src/main/kotlin/com/flipcash/app/advanced/internal/AdvancedFeatureMenuItems.kt b/apps/flipcash/features/advanced/src/main/kotlin/com/flipcash/app/advanced/internal/AdvancedFeatureMenuItems.kt index cd6cfc8b8..15ba3dae5 100644 --- a/apps/flipcash/features/advanced/src/main/kotlin/com/flipcash/app/advanced/internal/AdvancedFeatureMenuItems.kt +++ b/apps/flipcash/features/advanced/src/main/kotlin/com/flipcash/app/advanced/internal/AdvancedFeatureMenuItems.kt @@ -1,18 +1,16 @@ package com.flipcash.app.advanced.internal import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.AddCircle +import androidx.compose.material.icons.filled.Science import androidx.compose.material.icons.outlined.Description import androidx.compose.material.icons.outlined.Palette import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import com.flipcash.app.core.AppRoute -import com.flipcash.app.core.tokens.TokenPurpose import com.flipcash.app.menu.FullMenuItem -import com.flipcash.features.advanced.R +import com.flipcash.core.R internal data object BillCustomizer : FullMenuItem() { override val icon: Painter @@ -29,4 +27,14 @@ internal data object DeviceLogs : FullMenuItem() { + override val showBetaIndicator: Boolean = true + override val icon: Painter + @Composable get() = rememberVectorPainter(Icons.Filled.Science) + override val name: String + @Composable get() = stringResource(R.string.title_betaFlags) + override val action: AdvancedFeaturesScreenViewModel.Event = + AdvancedFeaturesScreenViewModel.Event.OpenScreen(AppRoute.Menu.Lab) } \ No newline at end of file diff --git a/apps/flipcash/features/advanced/src/main/kotlin/com/flipcash/app/advanced/internal/AdvancedFeaturesScreenViewModel.kt b/apps/flipcash/features/advanced/src/main/kotlin/com/flipcash/app/advanced/internal/AdvancedFeaturesScreenViewModel.kt index 38f795feb..521d41674 100644 --- a/apps/flipcash/features/advanced/src/main/kotlin/com/flipcash/app/advanced/internal/AdvancedFeaturesScreenViewModel.kt +++ b/apps/flipcash/features/advanced/src/main/kotlin/com/flipcash/app/advanced/internal/AdvancedFeaturesScreenViewModel.kt @@ -18,6 +18,7 @@ import javax.inject.Inject private val FullMenuList = buildList { add(BillCustomizer) add(DeviceLogs) + add(BetaFlags) } @HiltViewModel diff --git a/apps/flipcash/features/lab/src/main/kotlin/com/flipcash/app/lab/internal/LabsScreenContent.kt b/apps/flipcash/features/lab/src/main/kotlin/com/flipcash/app/lab/internal/LabsScreenContent.kt index a8d134c3e..721f84282 100644 --- a/apps/flipcash/features/lab/src/main/kotlin/com/flipcash/app/lab/internal/LabsScreenContent.kt +++ b/apps/flipcash/features/lab/src/main/kotlin/com/flipcash/app/lab/internal/LabsScreenContent.kt @@ -16,6 +16,7 @@ import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.rememberVectorPainter @@ -24,6 +25,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flipcash.app.core.AppRoute +import com.flipcash.app.featureflags.FeatureTrack import com.flipcash.app.featureflags.FlagOption import com.flipcash.app.featureflags.LocalFeatureFlags import com.flipcash.app.featureflags.message @@ -41,10 +43,16 @@ import com.getcode.ui.utils.sheetResignmentBehavior @Composable internal fun LabsScreenContent(viewModel: LabsScreenViewModel) { val betaFlagsController = LocalFeatureFlags.current - val betaFlags by betaFlagsController.observe().collectAsStateWithLifecycle() + val allFlags by betaFlagsController.observe().collectAsStateWithLifecycle() + val betaOverride by viewModel.betaOverride.collectAsStateWithLifecycle() val navigator = LocalCodeNavigator.current val isStaff by viewModel.isStaff.collectAsStateWithLifecycle() + val betaFlags = remember(allFlags, betaOverride) { + if (betaOverride) allFlags + else allFlags.filter { it.flag.minTrack == FeatureTrack.Production } + } + val state = rememberLazyListState() LazyColumn( modifier = Modifier @@ -119,22 +127,24 @@ internal fun LabsScreenContent(viewModel: LabsScreenViewModel) { } } - item(contentType = "section_header") { - SectionHeader( - modifier = Modifier.padding(horizontal = CodeTheme.dimens.inset), - title = stringResource(R.string.title_settingsSectionHomeScreen) - ) - } - item(contentType = "list_item") { - ListItem( - headline = stringResource(R.string.title_settingsButtonOrder), - icon = painterResource(R.drawable.ic_bottom_navigation), - ) { - navigator.navigate(AppRoute.Menu.NavBarSettings) + if (betaOverride) { + item(contentType = "section_header") { + SectionHeader( + modifier = Modifier.padding(horizontal = CodeTheme.dimens.inset), + title = stringResource(R.string.title_settingsSectionHomeScreen) + ) + } + item(contentType = "list_item") { + ListItem( + headline = stringResource(R.string.title_settingsButtonOrder), + icon = painterResource(R.drawable.ic_bottom_navigation), + ) { + navigator.navigate(AppRoute.Menu.NavBarSettings) + } } } - if (isStaff) { + if (betaOverride && isStaff) { item(contentType = "section_header") { SectionHeader( modifier = Modifier.padding(horizontal = CodeTheme.dimens.inset), diff --git a/apps/flipcash/features/lab/src/main/kotlin/com/flipcash/app/lab/internal/LabsScreenViewModel.kt b/apps/flipcash/features/lab/src/main/kotlin/com/flipcash/app/lab/internal/LabsScreenViewModel.kt index 15ccce643..89a85eaa9 100644 --- a/apps/flipcash/features/lab/src/main/kotlin/com/flipcash/app/lab/internal/LabsScreenViewModel.kt +++ b/apps/flipcash/features/lab/src/main/kotlin/com/flipcash/app/lab/internal/LabsScreenViewModel.kt @@ -2,6 +2,7 @@ package com.flipcash.app.lab.internal import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.flipcash.app.featureflags.FeatureFlagController import com.flipcash.app.userflags.UserFlagsCoordinator import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.SharingStarted @@ -12,8 +13,11 @@ import javax.inject.Inject @HiltViewModel class LabsScreenViewModel @Inject constructor( userFlags: UserFlagsCoordinator, + featureFlagController: FeatureFlagController, ) : ViewModel() { val isStaff = userFlags.resolvedFlags.map { it.isStaff.effectiveValue } .stateIn(viewModelScope, started = SharingStarted.WhileSubscribed(5_000), initialValue = false) + + val betaOverride = featureFlagController.observeOverride() } diff --git a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuItems.kt b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuItems.kt index 55b316b54..51ff9c891 100644 --- a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuItems.kt +++ b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuItems.kt @@ -1,10 +1,7 @@ package com.flipcash.app.menu.internal -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Science import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.painter.Painter -import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import com.flipcash.app.core.AppRoute @@ -53,12 +50,3 @@ internal data object SwitchAccount : StaffMenuItem() override val featureFlag: FeatureFlag<*> = FeatureFlag.CredentialManager } -internal data object Labs : StaffMenuItem() { - override val icon: Painter - @Composable get() = rememberVectorPainter(Icons.Filled.Science) - override val name: String - @Composable get() = stringResource(R.string.title_betaFlags) - override val action: MenuScreenViewModel.Event = MenuScreenViewModel.Event.OpenScreen( - AppRoute.Menu.Lab - ) -} diff --git a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenViewModel.kt b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenViewModel.kt index 794f60914..d5b2c74e2 100644 --- a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenViewModel.kt +++ b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenViewModel.kt @@ -42,7 +42,6 @@ private val FullMenuList = buildList { add(AppSettings) add(AdvancedFeatures) add(SwitchAccount) - add(Labs) } @HiltViewModel diff --git a/apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/FeatureFlag.kt b/apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/FeatureFlag.kt index 7a0ec644a..59f4d5f1f 100644 --- a/apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/FeatureFlag.kt +++ b/apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/FeatureFlag.kt @@ -7,6 +7,15 @@ import com.flipcash.app.ksp.annotations.FeatureFlagMarker import kotlin.time.Duration import kotlin.time.Duration.Companion.minutes +enum class FeatureTrack { + /** Visible to all users including production. */ + Production, + Alpha, + Beta, + /** Only visible on internal builds (or with beta override). */ + Internal, +} + data class FlagOption(val key: String, val label: String, val isDisabled: Boolean = false) sealed interface FeatureFlag { val key: String @@ -14,6 +23,7 @@ sealed interface FeatureFlag { val launched: Boolean val visible: Boolean val persistLogOut: Boolean + val minTrack: FeatureTrack get() = FeatureTrack.Internal val options: List get() = emptyList() val defaultOption: String get() = if (default is Enum<*>) (default as Enum<*>).name else "" @@ -187,6 +197,7 @@ sealed interface FeatureFlag { override val launched: Boolean = false override val visible: Boolean = true override val persistLogOut: Boolean = true + override val minTrack: FeatureTrack = FeatureTrack.Production } @FeatureFlagMarker diff --git a/apps/flipcash/shared/menu/src/main/kotlin/com/flipcash/app/menu/MenuItem.kt b/apps/flipcash/shared/menu/src/main/kotlin/com/flipcash/app/menu/MenuItem.kt index 8bcc0421b..c7e52b5fe 100644 --- a/apps/flipcash/shared/menu/src/main/kotlin/com/flipcash/app/menu/MenuItem.kt +++ b/apps/flipcash/shared/menu/src/main/kotlin/com/flipcash/app/menu/MenuItem.kt @@ -18,6 +18,8 @@ sealed interface MenuItem { val isStaffOnly: Boolean + val showBetaIndicator: Boolean get() = isStaffOnly + val featureFlag: FeatureFlag<*>? } diff --git a/apps/flipcash/shared/menu/src/main/kotlin/com/flipcash/app/menu/MenuList.kt b/apps/flipcash/shared/menu/src/main/kotlin/com/flipcash/app/menu/MenuList.kt index 5f1f64b70..c6056e90e 100644 --- a/apps/flipcash/shared/menu/src/main/kotlin/com/flipcash/app/menu/MenuList.kt +++ b/apps/flipcash/shared/menu/src/main/kotlin/com/flipcash/app/menu/MenuList.kt @@ -60,6 +60,6 @@ private fun ListItem( modifier = modifier, onClick = onClick, showChevron = showChevron, - showBetaIndicator = item.isStaffOnly, + showBetaIndicator = item.showBetaIndicator, ) } \ No newline at end of file From 8acd0944873215e510b28d3c58d587aa6f5f652f Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 15 Jun 2026 13:47:48 -0400 Subject: [PATCH 2/5] feat(flags): EOL messenger flag, merge into PhoneNumberSend Messenger is now always on when PhoneNumberSend is enabled. The separate Messenger toggle is removed - the send flow always uses the chat/messenger path since it is already guarded by PhoneNumberSend. - Mark Messenger flag as launched (default true) - Remove requiredFlag dependency and Messenger filter in controller - Simplify SendFlowViewModel: remove messengerEnabled branching, always navigate to chat for Flipcash contacts --- .../directsend/internal/SendFlowViewModel.kt | 208 +++++++----------- .../flipcash/app/featureflags/FeatureFlag.kt | 8 +- .../internal/InternalFeatureFlagController.kt | 6 - 3 files changed, 85 insertions(+), 137 deletions(-) diff --git a/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/SendFlowViewModel.kt b/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/SendFlowViewModel.kt index 75b013917..b7bcb921c 100644 --- a/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/SendFlowViewModel.kt +++ b/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/SendFlowViewModel.kt @@ -122,10 +122,9 @@ internal class SendFlowViewModel @Inject constructor( stateFlow.map { it.searchState }.distinctUntilChanged().flatMapLatest { snapshotFlow { it.text } }, chatCoordinator.feed, tokenCoordinator.tokens, - featureFlags.observe(FeatureFlag.Messenger), - ) { contactState, searchText, chatFeed, tokens, messengerOn -> + ) { contactState, searchText, chatFeed, tokens -> val tokensByMint = tokens.associateBy { it.address } - generateListItems(contactState, searchText.toString(), chatFeed, tokensByMint, messengerOn) + generateListItems(contactState, searchText.toString(), chatFeed, tokensByMint) }.onEach { items -> dispatchEvent(Event.OnItemsPopulated(items)) }.launchIn(viewModelScope) @@ -179,31 +178,12 @@ internal class SendFlowViewModel @Inject constructor( .onEach { row -> val (contact, isOnFlipcash) = row if (isOnFlipcash) { - if (featureFlags.get(FeatureFlag.Messenger)) { - val identifier = if (contact.e164.isNotEmpty()) { - ChatIdentifier.ByContact(contact.e164, contact.displayName, row.chatId) - } else { - ChatIdentifier.ByChatId(row.chatId!!) - } - dispatchEvent(Event.NavigateToChat(identifier)) + val identifier = if (contact.e164.isNotEmpty()) { + ChatIdentifier.ByContact(contact.e164, contact.displayName, row.chatId) } else { - if (!tokenCoordinator.hasGiveableBalance()) { - BottomBarManager.showInfo( - title = resources.getString(R.string.title_noBalanceYet), - message = resources.getString(R.string.description_noBalanceYet), - actions = listOf( - BottomBarAction( - text = resources.getString(R.string.action_depositFunds) - ) { - dispatchEvent(Event.PresentDepositOptions) - }, - ), - showCancel = true, - ) - return@onEach - } - dispatchEvent(Event.NavigateToDirectSend(contact)) + ChatIdentifier.ByChatId(row.chatId!!) } + dispatchEvent(Event.NavigateToChat(identifier)) } else { dispatchEvent(Event.SendInvite(contact)) } @@ -251,7 +231,6 @@ internal class SendFlowViewModel @Inject constructor( searchString: String, chatFeed: List, tokensByMint: Map, - messengerEnabled: Boolean, ): List = buildList { val allContacts = contactState.contacts.values.toList() val filtered = if (searchString.isBlank()) { @@ -265,109 +244,86 @@ internal class SendFlowViewModel @Inject constructor( val selfId = userManager.accountId - if (messengerEnabled) { - val dmChats = chatFeed.filter { it.metadata.type == ChatType.DM } - // Build a reverse lookup: e164 -> chatId string for contacts with DMs - val e164ToChatId = contactState.dmChatIds - - // Recents — driven by the chat feed, enriched with contact info - val recentsE164s = mutableSetOf() - val recentRows = dmChats.mapNotNull { summary -> - val chatId = summary.metadata.chatId - val chatIdStr = chatId.toString() - - // Try to match this chat to a device contact - val e164 = e164ToChatId.entries - .firstOrNull { it.value == chatIdStr }?.key - val deviceContact = e164?.let { contactState.contacts[it] } - - val contact = if (deviceContact != null) { - if (searchString.isNotBlank() && - !deviceContact.displayName.contains(searchString, ignoreCase = true) && - !deviceContact.e164.contains(searchString, ignoreCase = true)) { - return@mapNotNull null - } - recentsE164s += deviceContact.e164 - deviceContact - } else { - // Non-contact DM — build contact from chat member profile - val otherMember = summary.metadata.members - .firstOrNull { it.userId != selfId } ?: return@mapNotNull null - val phone = otherMember.userProfile.verifiedPhoneNumber - val formattedPhone = phone?.let { phoneUtils.formatNumber(it) } - val displayName = otherMember.userProfile.displayName?.takeIf { it.isNotBlank() } - ?: formattedPhone - ?: "Unknown Contact" - - val unknown = DeviceContact.unknownContact( - e164 = phone.orEmpty(), - displayName = displayName, - displayNumber = formattedPhone, - ) - if (searchString.isNotBlank() && - !unknown.displayName.contains(searchString, ignoreCase = true)) { - return@mapNotNull null - } - unknown + val dmChats = chatFeed.filter { it.metadata.type == ChatType.DM } + // Build a reverse lookup: e164 -> chatId string for contacts with DMs + val e164ToChatId = contactState.dmChatIds + + // Recents — driven by the chat feed, enriched with contact info + val recentsE164s = mutableSetOf() + val recentRows = dmChats.mapNotNull { summary -> + val chatId = summary.metadata.chatId + val chatIdStr = chatId.toString() + + // Try to match this chat to a device contact + val e164 = e164ToChatId.entries + .firstOrNull { it.value == chatIdStr }?.key + val deviceContact = e164?.let { contactState.contacts[it] } + + val contact = if (deviceContact != null) { + if (searchString.isNotBlank() && + !deviceContact.displayName.contains(searchString, ignoreCase = true) && + !deviceContact.e164.contains(searchString, ignoreCase = true)) { + return@mapNotNull null } - - ContactListItem.ContactRow( - contact = contact, - isOnFlipcash = true, - lastMessagePreview = formatPreview(summary, selfId, tokensByMint), - unreadCount = summary.unreadCount, - chatId = chatId, - lastActivity = summary.metadata.lastActivity, + recentsE164s += deviceContact.e164 + deviceContact + } else { + // Non-contact DM — build contact from chat member profile + val otherMember = summary.metadata.members + .firstOrNull { it.userId != selfId } ?: return@mapNotNull null + val phone = otherMember.userProfile.verifiedPhoneNumber + val formattedPhone = phone?.let { phoneUtils.formatNumber(it) } + val displayName = otherMember.userProfile.displayName?.takeIf { it.isNotBlank() } + ?: formattedPhone + ?: "Unknown Contact" + + val unknown = DeviceContact.unknownContact( + e164 = phone.orEmpty(), + displayName = displayName, + displayNumber = formattedPhone, ) - }.sortedWith( - compareByDescending { it.lastActivity } - .thenBy(String.CASE_INSENSITIVE_ORDER) { it.contact.displayName } - ) - - // On Flipcash — contacts that haven't chatted yet - val flipcashRows = filtered - .filter { it.e164 in contactState.flipcashE164s && it.e164 !in recentsE164s } - .sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.displayName }) - .map { ContactListItem.ContactRow(contact = it, isOnFlipcash = true) } - - val excludedE164s = recentsE164s + contactState.flipcashE164s - val other = filtered - .filter { it.e164 !in excludedE164s } - .sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.displayName }) - - if (recentRows.isNotEmpty()) { - add(ContactListItem.Header(resources.getString(R.string.title_recents))) - addAll(recentRows) - } - if (flipcashRows.isNotEmpty()) { - add(ContactListItem.Header(resources.getString(R.string.title_flipcashContacts))) - addAll(flipcashRows) - } - if (other.isNotEmpty()) { - add(ContactListItem.Header(resources.getString(R.string.title_nonFlipcashContacts))) - other.forEach { add(ContactListItem.ContactRow(it, isOnFlipcash = false)) } + if (searchString.isNotBlank() && + !unknown.displayName.contains(searchString, ignoreCase = true)) { + return@mapNotNull null + } + unknown } - } else { - val flipcashRows = filtered - .filter { it.e164 in contactState.flipcashE164s } - .sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.displayName }) - .map { ContactListItem.ContactRow(contact = it, isOnFlipcash = true) } - - val flipcashE164s = flipcashRows.mapTo(mutableSetOf()) { it.contact.e164 } - .plus(contactState.flipcashE164s) - val other = filtered - .filter { it.e164 !in flipcashE164s } - .sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.displayName }) - - if (flipcashRows.isNotEmpty()) { - add(ContactListItem.Header(resources.getString(R.string.title_flipcashContacts))) - addAll(flipcashRows) - } - if (other.isNotEmpty()) { - add(ContactListItem.Header(resources.getString(R.string.title_nonFlipcashContacts))) - other.forEach { add(ContactListItem.ContactRow(it, isOnFlipcash = false)) } - } + ContactListItem.ContactRow( + contact = contact, + isOnFlipcash = true, + lastMessagePreview = formatPreview(summary, selfId, tokensByMint), + unreadCount = summary.unreadCount, + chatId = chatId, + lastActivity = summary.metadata.lastActivity, + ) + }.sortedWith( + compareByDescending { it.lastActivity } + .thenBy(String.CASE_INSENSITIVE_ORDER) { it.contact.displayName } + ) + + // On Flipcash — contacts that haven't chatted yet + val flipcashRows = filtered + .filter { it.e164 in contactState.flipcashE164s && it.e164 !in recentsE164s } + .sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.displayName }) + .map { ContactListItem.ContactRow(contact = it, isOnFlipcash = true) } + + val excludedE164s = recentsE164s + contactState.flipcashE164s + val other = filtered + .filter { it.e164 !in excludedE164s } + .sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.displayName }) + + if (recentRows.isNotEmpty()) { + add(ContactListItem.Header(resources.getString(R.string.title_recents))) + addAll(recentRows) + } + if (flipcashRows.isNotEmpty()) { + add(ContactListItem.Header(resources.getString(R.string.title_flipcashContacts))) + addAll(flipcashRows) + } + if (other.isNotEmpty()) { + add(ContactListItem.Header(resources.getString(R.string.title_nonFlipcashContacts))) + other.forEach { add(ContactListItem.ContactRow(it, isOnFlipcash = false)) } } } diff --git a/apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/FeatureFlag.kt b/apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/FeatureFlag.kt index 59f4d5f1f..a66ec67fe 100644 --- a/apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/FeatureFlag.kt +++ b/apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/FeatureFlag.kt @@ -203,12 +203,10 @@ sealed interface FeatureFlag { @FeatureFlagMarker data object Messenger : FeatureFlag { override val key: String = "messenger_enabled" - override val default: Boolean = false - override val launched: Boolean = false + override val default: Boolean = true + override val launched: Boolean = true override val visible: Boolean = true override val persistLogOut: Boolean = false - /** Messenger only makes sense when [PhoneNumberSend] is enabled at runtime. */ - val requiredFlag: FeatureFlag get() = PhoneNumberSend } @FeatureFlagMarker @@ -275,7 +273,7 @@ val FeatureFlag<*>.message: String FeatureFlag.DepositUsdc -> "When enabled, you'll gain the ability to deposit USDC directly from any external wallet app instead of purchasing a currency first and sell" FeatureFlag.BackgroundReset -> "Automatically returns the app to the camera screen after a period of inactivity with the app in the background" FeatureFlag.ContactPickerMode -> "When enabled, contacts will be accessed via the system contact picker instead of requesting full READ_CONTACTS permission" - FeatureFlag.PhoneNumberSend -> "When enabled, you'll gain the ability to send cash directly to contacts via phone number" + FeatureFlag.PhoneNumberSend -> "When enabled, you'll gain the ability to send cash directly to contacts via phone number and chat with them using the messenger" FeatureFlag.Messenger -> "When enabled, tapping a contact will open the chat messenger instead of navigating directly to send" FeatureFlag.NavBar -> "Customize the order and labels of navigation bar buttons" } diff --git a/apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/internal/InternalFeatureFlagController.kt b/apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/internal/InternalFeatureFlagController.kt index 09975beae..3de67c14d 100644 --- a/apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/internal/InternalFeatureFlagController.kt +++ b/apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/internal/InternalFeatureFlagController.kt @@ -98,12 +98,6 @@ internal class InternalFeatureFlagController @Inject constructor( override fun observe(): StateFlow> = betaFlags.data.map { prefs -> FeatureFlag.availableEntries - .filter { flag -> - if (flag is FeatureFlag.Messenger) { - val req = flag.requiredFlag - prefs[req.booleanPreferenceKey] ?: req.defaultEnabled - } else true - } .map { flag -> if (flag.isOptionFlag) { val option = prefs[flag.optionPreferenceKey] ?: flag.defaultOption From 5c67fd73efd6aaaabc125967b9f8e113c656d268 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 15 Jun 2026 14:04:15 -0400 Subject: [PATCH 3/5] feat(flags): add PhoneVerification beta flag for onboarding Add a new Internal-track beta flag to gate phone number verification during onboarding independently from PhoneNumberSend. Contact permissions remain tied to PhoneNumberSend. Signed-off-by: Brandon McAnsh --- .../com/flipcash/app/login/OnboardingFlowScreen.kt | 2 +- .../com/flipcash/app/login/router/LoginViewModel.kt | 6 +++--- .../com/flipcash/app/featureflags/FeatureFlag.kt | 13 +++++++++++-- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/OnboardingFlowScreen.kt b/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/OnboardingFlowScreen.kt index c0b3bcecf..bee7d74b8 100644 --- a/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/OnboardingFlowScreen.kt +++ b/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/OnboardingFlowScreen.kt @@ -98,7 +98,7 @@ import kotlinx.coroutines.flow.onEach * **and** [FeatureFlag.ContactPickerMode] is off. When ContactPickerMode is on, * contacts are accessed via the system picker at call site (no READ_CONTACTS needed). * Already-granted permissions are auto-skipped via [PermissionsPhaseFlowHost]. - * ² Phone verification is shown only when [FeatureFlag.PhoneNumberSend] is enabled + * ² Phone verification is shown only when [FeatureFlag.OnboardingPhoneVerification] is enabled * and no phone is linked. Skipped entirely when the flag is off. * Uses `target` to replace the nav stack with AccessKey on success. */ diff --git a/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/router/LoginViewModel.kt b/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/router/LoginViewModel.kt index dd1a9069e..ceca3d11b 100644 --- a/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/router/LoginViewModel.kt +++ b/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/router/LoginViewModel.kt @@ -66,9 +66,9 @@ class LoginViewModel @Inject constructor( init { combine( userManager.state, - featureFlags.observe(FeatureFlag.PhoneNumberSend), - ) { userState, phoneNumberSendFlag -> - val enabled = phoneNumberSendFlag || userState.flags?.enablePhoneNumberSend == true + featureFlags.observe(FeatureFlag.OnboardingPhoneVerification), + ) { userState, phoneVerificationFlag -> + val enabled = phoneVerificationFlag || userState.flags?.enablePhoneNumberSend == true val hasLinkedPhone = userState.userProfile?.verifiedPhoneNumber != null enabled && !hasLinkedPhone }.onEach { needed -> diff --git a/apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/FeatureFlag.kt b/apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/FeatureFlag.kt index a66ec67fe..dc4a6bec4 100644 --- a/apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/FeatureFlag.kt +++ b/apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/FeatureFlag.kt @@ -4,8 +4,6 @@ import android.os.Build import com.flipcash.app.featureflags.model.BackgroundResetTimeout import com.flipcash.app.core.navigation.NavBarConfig import com.flipcash.app.ksp.annotations.FeatureFlagMarker -import kotlin.time.Duration -import kotlin.time.Duration.Companion.minutes enum class FeatureTrack { /** Visible to all users including production. */ @@ -200,6 +198,15 @@ sealed interface FeatureFlag { override val minTrack: FeatureTrack = FeatureTrack.Production } + @FeatureFlagMarker + data object OnboardingPhoneVerification : FeatureFlag { + override val key: String = "phone_verification_onboarding_enabled" + override val default: Boolean = false + override val launched: Boolean = false + override val visible: Boolean = true + override val persistLogOut: Boolean = true + } + @FeatureFlagMarker data object Messenger : FeatureFlag { override val key: String = "messenger_enabled" @@ -250,6 +257,7 @@ val FeatureFlag<*>.title: String FeatureFlag.BackgroundReset -> "Background Reset" FeatureFlag.ContactPickerMode -> "Contact Picker Mode" FeatureFlag.PhoneNumberSend -> "Phone Number Send" + FeatureFlag.OnboardingPhoneVerification -> "Onboarding Phone Verification" FeatureFlag.Messenger -> "Messenger" FeatureFlag.NavBar -> "Navigation Bar" } @@ -274,6 +282,7 @@ val FeatureFlag<*>.message: String FeatureFlag.BackgroundReset -> "Automatically returns the app to the camera screen after a period of inactivity with the app in the background" FeatureFlag.ContactPickerMode -> "When enabled, contacts will be accessed via the system contact picker instead of requesting full READ_CONTACTS permission" FeatureFlag.PhoneNumberSend -> "When enabled, you'll gain the ability to send cash directly to contacts via phone number and chat with them using the messenger" + FeatureFlag.OnboardingPhoneVerification -> "When enabled, new accounts will be prompted to verify their phone number during onboarding" FeatureFlag.Messenger -> "When enabled, tapping a contact will open the chat messenger instead of navigating directly to send" FeatureFlag.NavBar -> "Customize the order and labels of navigation bar buttons" } From 3c73fa1e4ac7495247d75c01e42ffdf37b897d98 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 15 Jun 2026 14:27:21 -0400 Subject: [PATCH 4/5] feat(tokens): add GiveUsdf flag with auto-switch on disable Signed-off-by: Brandon McAnsh --- .../com/flipcash/app/featureflags/FeatureFlag.kt | 11 +++++++++++ .../com/flipcash/app/tokens/TokenCoordinator.kt | 13 +++++++++++++ .../app/tokens/TokenSelectionResolver.kt | 9 ++++++--- .../app/tokens/ui/SelectTokenViewModel.kt | 16 ++++++++++++++++ 4 files changed, 46 insertions(+), 3 deletions(-) diff --git a/apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/FeatureFlag.kt b/apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/FeatureFlag.kt index dc4a6bec4..8a2988999 100644 --- a/apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/FeatureFlag.kt +++ b/apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/FeatureFlag.kt @@ -216,6 +216,15 @@ sealed interface FeatureFlag { override val persistLogOut: Boolean = false } + @FeatureFlagMarker + data object GiveUsdf: FeatureFlag { + override val key: String = "give_usdf_enabled" + override val default: Boolean = false + override val launched: Boolean = false + override val visible: Boolean = true + override val persistLogOut: Boolean = false + } + @FeatureFlagMarker data object NavBar : FeatureFlag { override val key: String = "nav_bar_config" @@ -260,6 +269,7 @@ val FeatureFlag<*>.title: String FeatureFlag.OnboardingPhoneVerification -> "Onboarding Phone Verification" FeatureFlag.Messenger -> "Messenger" FeatureFlag.NavBar -> "Navigation Bar" + FeatureFlag.GiveUsdf -> "Give USDF" } val FeatureFlag<*>.message: String @@ -285,6 +295,7 @@ val FeatureFlag<*>.message: String FeatureFlag.OnboardingPhoneVerification -> "When enabled, new accounts will be prompted to verify their phone number during onboarding" FeatureFlag.Messenger -> "When enabled, tapping a contact will open the chat messenger instead of navigating directly to send" FeatureFlag.NavBar -> "Customize the order and labels of navigation bar buttons" + FeatureFlag.GiveUsdf -> "When enabled, you'll gain the ability to send USDF directly" } diff --git a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenCoordinator.kt b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenCoordinator.kt index 4732c426b..817119705 100644 --- a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenCoordinator.kt +++ b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenCoordinator.kt @@ -10,6 +10,8 @@ import androidx.datastore.preferences.preferencesDataStoreFile import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.ProcessLifecycleOwner +import com.flipcash.app.featureflags.FeatureFlag +import com.flipcash.app.featureflags.FeatureFlagController import com.flipcash.app.persistence.sources.TokenDataSource import com.flipcash.app.tokens.core.ReservesBalanceProvider import com.getcode.opencode.controllers.AccountController @@ -85,6 +87,7 @@ class TokenCoordinator @Inject constructor( private val exchange: Exchange, private val verifiedFiatCalculator: VerifiedFiatCalculator, private val dataSource: TokenDataSource, + private val featureFlags: FeatureFlagController, ) : TokenMetadataProvider, SessionListener, DefaultLifecycleObserver, ReservesBalanceProvider { companion object { @@ -166,6 +169,12 @@ class TokenCoordinator @Inject constructor( exchange.updateUserMints(mints) } .launchIn(scope) + + // Re-evaluate selected token when GiveUsdf flag changes + featureFlags.observe(FeatureFlag.GiveUsdf) + .filter { _hydrated.value } + .onEach { ensureValidTokenSelection() } + .launchIn(scope) } override fun onStart(owner: LifecycleOwner) { @@ -516,10 +525,14 @@ class TokenCoordinator @Inject constructor( ?.get(mintPreferenceKey) ?.let { Mint(it) } + val canGiveUsdf = featureFlags.get(FeatureFlag.GiveUsdf) + val excludedMints = if (!canGiveUsdf) setOf(Mint.usdf) else emptySet() + val resolved = resolveTokenSelection( balances = _state.value.balances, currentSelection = currentSelection, rate = exchange.preferredRate, + excludedMints = excludedMints, ) if (resolved != null && resolved != currentSelection) { diff --git a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenSelectionResolver.kt b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenSelectionResolver.kt index 668210a0b..7b7c6756f 100644 --- a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenSelectionResolver.kt +++ b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenSelectionResolver.kt @@ -15,9 +15,12 @@ internal fun resolveTokenSelection( balances: Map, currentSelection: Mint?, rate: Rate, + excludedMints: Set = emptySet(), ): Mint? { if (balances.isEmpty()) return null + val eligible = balances.filterKeys { it !in excludedMints } + val baseline = 0.01.toFiat(rate.currency) fun Fiat.meetsThreshold(): Boolean { @@ -26,15 +29,15 @@ internal fun resolveTokenSelection( } // Keep current selection if it still meets the threshold - if (currentSelection != null) { - val balance = balances[currentSelection] + if (currentSelection != null && currentSelection !in excludedMints) { + val balance = eligible[currentSelection] if (balance != null && balance.meetsThreshold()) { return currentSelection } } // Fall back to the highest balance that meets the threshold - return balances + return eligible .filter { it.value.meetsThreshold() } .maxByOrNull { it.value } ?.key diff --git a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SelectTokenViewModel.kt b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SelectTokenViewModel.kt index f0147b7e3..2ce7ea16c 100644 --- a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SelectTokenViewModel.kt +++ b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SelectTokenViewModel.kt @@ -43,6 +43,7 @@ class SelectTokenViewModel @Inject constructor( exchange: Exchange, resources: ResourceHelper, dispatchers: DispatcherProvider, + featureFlags: FeatureFlagController, ) : BaseViewModel( initialState = State(purpose = TokenPurpose.Balance), updateStateForEvent = updateStateForEvent, @@ -52,6 +53,7 @@ class SelectTokenViewModel @Inject constructor( data class State( val purpose: TokenPurpose, val rate: Rate = Rate.oneToOne, + val canGiveUsdf: Boolean = false, val discoveryEnabled: Boolean = false, val tokens: List? = null, val selectedToken: Mint? = null, @@ -85,6 +87,8 @@ class SelectTokenViewModel @Inject constructor( data object OnTokenChanged : Event data class OpenScreen(val route: AppRoute) : Event + + data class OnCanGiveUsdf(val enabled: Boolean): Event } init { @@ -93,6 +97,10 @@ class SelectTokenViewModel @Inject constructor( .onEach { dispatchEvent(Event.OnRateChanged(it)) } .launchIn(viewModelScope) + featureFlags.observe(FeatureFlag.GiveUsdf) + .onEach { dispatchEvent(Event.OnCanGiveUsdf(it)) } + .launchIn(viewModelScope) + eventFlow .filterIsInstance() .map { it.purpose } @@ -156,6 +164,13 @@ class SelectTokenViewModel @Inject constructor( when (purpose) { // show all tokens we have accounts for as deposit targets TokenPurpose.Deposit -> true + TokenPurpose.Select -> { + if (it.token.address == Mint.usdf) { + stateFlow.value.canGiveUsdf && hasBalance + } else { + hasBalance + } + } // show all tokens with non-zero balance else -> hasBalance } @@ -191,6 +206,7 @@ class SelectTokenViewModel @Inject constructor( is Event.OnTokenSelected -> { state -> state.copy(selectedToken = event.mint) } is Event.OnTokenChanged -> { state -> state } is Event.OpenScreen -> { state -> state } + is Event.OnCanGiveUsdf -> { state -> state.copy(canGiveUsdf = event.enabled) } } } } From 38ec0b476aafe138482a6acd7745172b4095cc7f Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 15 Jun 2026 15:29:08 -0400 Subject: [PATCH 5/5] feat: move deposit-first new user UX behind beta flag Signed-off-by: Brandon McAnsh --- .../balance/internal/BalanceScreenContent.kt | 15 +++- .../app/balance/internal/BalanceViewModel.kt | 19 +++++- .../directsend/internal/SendFlowViewModel.kt | 17 +---- .../internal/screens/ContactListScreen.kt | 25 ------- .../app/menu/internal/MenuScreenViewModel.kt | 11 ++- .../features/messenger/build.gradle.kts | 1 + .../flipcash/app/messenger/ChatFlowScreen.kt | 2 +- .../app/messenger/internal/ChatViewModel.kt | 31 +++++++-- .../flipcash/app/scanner/internal/Scanner.kt | 18 ++--- .../app/tokens/internal/TokenInfoScreen.kt | 21 +++--- .../flipcash/app/featureflags/FeatureFlag.kt | 11 +++ .../InternalPurchaseMethodController.kt | 3 +- .../flipcash/app/session/SessionController.kt | 1 + .../session/internal/RealSessionController.kt | 68 +++++++++++++++++-- .../app/tokens/ui/TokenInfoViewModel.kt | 16 ++++- 15 files changed, 172 insertions(+), 87 deletions(-) diff --git a/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/BalanceScreenContent.kt b/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/BalanceScreenContent.kt index 4223698c6..f34bf2313 100644 --- a/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/BalanceScreenContent.kt +++ b/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/BalanceScreenContent.kt @@ -43,8 +43,10 @@ internal fun BalanceScreen( viewModel: BalanceViewModel, tokenViewModel: SelectTokenViewModel, ) { + val balanceState by viewModel.stateFlow.collectAsStateWithLifecycle() val tokenState by tokenViewModel.stateFlow.collectAsStateWithLifecycle() BalanceScreenContent( + depositFirstUx = balanceState.depositFirstUx, tokenState = tokenState, dispatchEvent = viewModel::dispatchEvent ) @@ -52,6 +54,7 @@ internal fun BalanceScreen( @Composable private fun BalanceScreenContent( + depositFirstUx: Boolean = false, tokenState: SelectTokenViewModel.State, dispatchEvent: (BalanceViewModel.Event) -> Unit ) { @@ -97,7 +100,11 @@ private fun BalanceScreenContent( Text( modifier = Modifier.fillMaxWidth(0.6f), - text = stringResource(R.string.description_noBalanceYet), + text = if (depositFirstUx) { + stringResource(R.string.description_noBalanceYet) + } else { + stringResource(R.string.description_noBalanceYetDiscover) + }, style = CodeTheme.typography.textSmall, color = CodeTheme.colors.textSecondary, textAlign = TextAlign.Center, @@ -111,7 +118,11 @@ private fun BalanceScreenContent( .padding(top = CodeTheme.dimens.grid.x2) .align(Alignment.CenterHorizontally), contentPadding = PaddingValues(), - text = stringResource(R.string.action_depositFunds), + text = if (depositFirstUx) { + stringResource(R.string.action_depositFunds) + } else { + stringResource(R.string.action_discoverCurrencies) + }, shape = CircleShape, ) } diff --git a/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/BalanceViewModel.kt b/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/BalanceViewModel.kt index 632591677..7c12c622b 100644 --- a/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/BalanceViewModel.kt +++ b/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/BalanceViewModel.kt @@ -2,6 +2,8 @@ package com.flipcash.app.balance.internal import androidx.lifecycle.viewModelScope import com.flipcash.app.core.AppRoute +import com.flipcash.app.featureflags.FeatureFlag +import com.flipcash.app.featureflags.FeatureFlagController import com.flipcash.app.payments.PurchaseMethodController import com.flipcash.app.userflags.UserFlagsCoordinator import com.flipcash.services.internal.model.thirdparty.OnRampProvider @@ -25,16 +27,19 @@ internal class BalanceViewModel @Inject constructor( userFlags: UserFlagsCoordinator, dispatchers: DispatcherProvider, purchaseMethodController: PurchaseMethodController, + featureFlags: FeatureFlagController, ) : BaseViewModel( initialState = State(), updateStateForEvent = updateStateForEvent, defaultDispatcher = dispatchers.Default, ) { data class State( + val depositFirstUx: Boolean = false, val preferredOnRampProvider: OnRampProvider.Defined? = null, ) sealed interface Event { + data class DepositFirstUxEnabled(val enabled: Boolean): Event data class OnPreferredOnRampProviderChanged(val provider: OnRampProvider.Defined?) : Event data object OpenCurrencySelection : Event @@ -44,17 +49,26 @@ internal class BalanceViewModel @Inject constructor( } init { + featureFlags.observe(FeatureFlag.DepositFirstUX) + .onEach { dispatchEvent(Event.DepositFirstUxEnabled(it)) } + .launchIn(viewModelScope) + userManager.state .filter { it.authState is AuthState.Ready } .flatMapLatest { userFlags.resolvedFlags } .mapNotNull { it.preferredOnRampProvider.effectiveValue } - .filterIsInstance() .onEach { provider -> dispatchEvent(Event.OnPreferredOnRampProviderChanged(provider)) } .launchIn(viewModelScope) eventFlow .filterIsInstance() - .mapNotNull { purchaseMethodController.presentDepositOptions(popToRoot = true) } + .map { stateFlow.value.depositFirstUx } + .mapNotNull { depositFirstUx -> + if (!depositFirstUx) { + dispatchEvent(Event.OpenScreen(AppRoute.Token.Discovery)) + return@mapNotNull null + } + purchaseMethodController.presentDepositOptions(popToRoot = true) } .onEach { route -> dispatchEvent(Event.OpenScreen(route)) } .launchIn(viewModelScope) } @@ -68,6 +82,7 @@ internal class BalanceViewModel @Inject constructor( } Event.PresentDepositOptions -> { state -> state } is Event.OpenScreen -> { state -> state } + is Event.DepositFirstUxEnabled -> { state -> state.copy(depositFirstUx = event.enabled) } } } } diff --git a/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/SendFlowViewModel.kt b/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/SendFlowViewModel.kt index b7bcb921c..24eb618fd 100644 --- a/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/SendFlowViewModel.kt +++ b/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/SendFlowViewModel.kt @@ -52,10 +52,9 @@ internal class SendFlowViewModel @Inject constructor( featureFlags: FeatureFlagController, private val contactCoordinator: ContactCoordinator, chatCoordinator: ChatCoordinator, - private val tokenCoordinator: TokenCoordinator, + tokenCoordinator: TokenCoordinator, private val phoneUtils: PhoneUtils, private val resources: ResourceHelper, - purchaseMethodController: PurchaseMethodController, ) : BaseViewModel( initialState = State(), updateStateForEvent = updateStateForEvent, @@ -89,9 +88,6 @@ internal class SendFlowViewModel @Inject constructor( data class SendInvite(val contact: DeviceContact) : Event data class NavigateToChat(val identifier: ChatIdentifier) : Event - data class NavigateToDirectSend(val contact: DeviceContact) : Event - data object PresentDepositOptions : Event - data class NavigateToUsdfDepositOption(val route: AppRoute): Event } init { @@ -189,14 +185,6 @@ internal class SendFlowViewModel @Inject constructor( } }.launchIn(viewModelScope) - eventFlow - .filterIsInstance() - .onEach { - purchaseMethodController.presentDepositOptions()?.let { route -> - dispatchEvent(Event.NavigateToUsdfDepositOption(route)) - } - }.launchIn(viewModelScope) - eventFlow .filterIsInstance() .onEach { event -> contactCoordinator.removeContact(event.e164) } @@ -375,9 +363,6 @@ internal class SendFlowViewModel @Inject constructor( is Event.OnContactClicked -> { state -> state } is Event.SendInvite -> { state -> state } is Event.NavigateToChat -> { state -> state } - is Event.NavigateToDirectSend -> { state -> state } - is Event.PresentDepositOptions -> { state -> state } - is Event.NavigateToUsdfDepositOption -> { state -> state } } } } diff --git a/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/screens/ContactListScreen.kt b/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/screens/ContactListScreen.kt index 1e88910b9..571a703db 100644 --- a/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/screens/ContactListScreen.kt +++ b/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/screens/ContactListScreen.kt @@ -94,31 +94,6 @@ internal fun ContactListScreen() { } } - LaunchedEffect(viewModel) { - viewModel.eventFlow - .filterIsInstance() - .map { it.contact } - .collect { contact -> - flowNavigator.navigate( - AppRoute.Messaging.AmountEntry( - identifier = ChatIdentifier.ByContact( - e164 = contact.e164, - displayName = contact.displayName, - ) - ) - ) - } - } - - LaunchedEffect(viewModel) { - viewModel.eventFlow - .filterIsInstance() - .map { it.route } - .collect { route -> - flowNavigator.navigate(route) - } - } - val accessHandle = rememberContactAccessHandle( isPickerMode = state.isPickerMode, ) { result -> diff --git a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenViewModel.kt b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenViewModel.kt index d5b2c74e2..44c945caa 100644 --- a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenViewModel.kt +++ b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenViewModel.kt @@ -6,6 +6,7 @@ import com.flipcash.app.core.AppRoute import com.flipcash.app.core.android.VersionInfo import com.flipcash.app.core.extensions.onResult import com.flipcash.app.featureflags.BetaFeature +import com.flipcash.app.featureflags.FeatureFlag import com.flipcash.app.featureflags.FeatureFlagController import com.flipcash.app.menu.MenuItem import com.flipcash.app.payments.PurchaseMethodController @@ -151,8 +152,14 @@ internal class MenuScreenViewModel @Inject constructor( eventFlow .filterIsInstance() - .mapNotNull { purchaseMethodController.presentDepositOptions(popToRoot = true) } - .onEach { route -> dispatchEvent(Event.OpenScreen(route)) } + .mapNotNull { + val depositFirstUx = featureFlags.get(FeatureFlag.DepositFirstUX) + if (!depositFirstUx) { + return@mapNotNull AppRoute.Transfers.Deposit() + } + + purchaseMethodController.presentDepositOptions(popToRoot = true) + }.onEach { route -> dispatchEvent(Event.OpenScreen(route)) } .launchIn(viewModelScope) } diff --git a/apps/flipcash/features/messenger/build.gradle.kts b/apps/flipcash/features/messenger/build.gradle.kts index 63709ee3a..278004a4c 100644 --- a/apps/flipcash/features/messenger/build.gradle.kts +++ b/apps/flipcash/features/messenger/build.gradle.kts @@ -10,6 +10,7 @@ dependencies { implementation(project(":apps:flipcash:shared:chat")) implementation(project(":apps:flipcash:shared:amount-entry")) implementation(project(":apps:flipcash:shared:contacts")) + implementation(project(":apps:flipcash:shared:featureflags")) implementation(project(":apps:flipcash:shared:payments")) implementation(project(":apps:flipcash:shared:tokens")) implementation(project(":libs:messaging")) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt index c35acc1c6..ccb1aeb81 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt @@ -70,7 +70,7 @@ private fun FlowConversationScreen(identifier: ChatIdentifier) { LaunchedEffect(viewModel) { viewModel.eventFlow - .filterIsInstance() + .filterIsInstance() .map { it.route } .collect { route -> flowNavigator.navigate(route) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt index ff63a4393..c320688e9 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt @@ -14,6 +14,8 @@ import com.flipcash.app.core.AppRoute import com.flipcash.app.core.chat.ChatIdentifier import com.flipcash.app.core.extensions.onResult import com.flipcash.app.core.ui.ConfirmationStyle +import com.flipcash.app.featureflags.FeatureFlag +import com.flipcash.app.featureflags.FeatureFlagController import com.flipcash.app.messenger.internal.screens.components.ChatListItem import com.flipcash.app.messenger.internal.screens.components.ReceiptStatus import com.flipcash.app.messenger.internal.screens.components.SeparatorConfig @@ -88,6 +90,7 @@ internal class ChatViewModel @Inject constructor( private val purchaseMethodController: PurchaseMethodController, private val userManager: UserManager, private val resources: ResourceHelper, + private val featureFlags: FeatureFlagController, ) : BaseViewModel( initialState = State(), updateStateForEvent = updateStateForEvent, @@ -133,8 +136,7 @@ internal class ChatViewModel @Inject constructor( data class NavigateToAmountEntry(val contact: DeviceContact) : Event data object PresentDepositOptions : Event - data class NavigateToUsdfDepositOption(val route: AppRoute): Event - + data class OpenScreen(val route: AppRoute): Event data object OnConfirmRequested : Event data class OnSendRequested( val amount: Fiat, @@ -444,14 +446,29 @@ internal class ChatViewModel @Inject constructor( .mapNotNull { stateFlow.value.chattingWith } .onEach { contact -> if (!tokenCoordinator.hasGiveableBalance()) { + val depositFirst = featureFlags.get(FeatureFlag.DepositFirstUX) + val message = if (depositFirst) { + resources.getString(R.string.description_noBalanceYet) + } else { + resources.getString(R.string.description_noBalanceYetDiscover) + } + val cta = if (depositFirst) { + resources.getString(R.string.action_depositFunds) + } else { + resources.getString(R.string.action_discover) + } BottomBarManager.showInfo( title = resources.getString(R.string.title_noBalanceYet), - message = resources.getString(R.string.description_noBalanceYet), + message = message, actions = listOf( BottomBarAction( - text = resources.getString(R.string.action_depositFunds) + text = cta ) { - dispatchEvent(Event.PresentDepositOptions) + if (depositFirst) { + dispatchEvent(Event.PresentDepositOptions) + } else { + dispatchEvent(Event.OpenScreen(AppRoute.Token.Discovery)) + } }, ), showCancel = true, @@ -466,7 +483,7 @@ internal class ChatViewModel @Inject constructor( .filterIsInstance() .onEach { purchaseMethodController.presentDepositOptions()?.let { route -> - dispatchEvent(Event.NavigateToUsdfDepositOption(route)) + dispatchEvent(Event.OpenScreen(route)) } }.launchIn(viewModelScope) @@ -625,7 +642,7 @@ internal class ChatViewModel @Inject constructor( is Event.SendMessage -> { state -> state } is Event.NavigateToAmountEntry -> { state -> state.copy(sendProgress = LoadingSuccessState()) } is Event.PresentDepositOptions -> { state -> state } - is Event.NavigateToUsdfDepositOption -> { state -> state } + is Event.OpenScreen -> { state -> state } is Event.OnConfirmRequested -> { state -> state } is Event.OnSendRequested -> { state -> state } is Event.SendStateUpdated -> { state -> diff --git a/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/Scanner.kt b/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/Scanner.kt index a5e621521..1dac9268e 100644 --- a/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/Scanner.kt +++ b/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/Scanner.kt @@ -87,20 +87,10 @@ internal fun Scanner() { ScannerDecorItem.Give -> { // only allow navigation to give when there is something to give if (!state.hasGiveableBalance) { - BottomBarManager.showInfo( - title = context.getString(R.string.title_noBalanceYet), - message = context.getString(R.string.description_noBalanceYet), - actions = listOf( - BottomBarAction( - text = context.getString(R.string.action_depositFunds) - ) { - session.presentDepositOptions { route -> - navigator.openAsSheet(route) - } - }, - ), - showCancel = true, - ) + session.presentDepositOptions { route -> + navigator.openAsSheet(route) + } + return@BillContainer } } diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/TokenInfoScreen.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/TokenInfoScreen.kt index 12f8b82f2..0a9458730 100644 --- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/TokenInfoScreen.kt +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/TokenInfoScreen.kt @@ -325,17 +325,20 @@ private fun RowScope.ReserveButtonOptions( val hasBalance = state.balance.nativeAmount.isPositive if (hasBalance) { - CodeButton( - modifier = Modifier.weight(1f), - buttonState = ButtonState.Filled, - text = stringResource(R.string.action_give), - ) { - dispatch( - TokenInfoViewModel.Event.OpenScreen( - AppRoute.Sheets.Give(mint = mint, fromTokenInfo = true) + if (mint == Mint.usdf && state.canGiveUsdf || mint != Mint.usdf) { + CodeButton( + modifier = Modifier.weight(1f), + buttonState = ButtonState.Filled, + text = stringResource(R.string.action_give), + ) { + dispatch( + TokenInfoViewModel.Event.OpenScreen( + AppRoute.Sheets.Give(mint = mint, fromTokenInfo = true) + ) ) - ) + } } + CodeButton( modifier = Modifier.weight(1f), buttonState = ButtonState.Filled20, diff --git a/apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/FeatureFlag.kt b/apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/FeatureFlag.kt index 8a2988999..66889c33b 100644 --- a/apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/FeatureFlag.kt +++ b/apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/FeatureFlag.kt @@ -235,6 +235,15 @@ sealed interface FeatureFlag { override val defaultOption: String get() = default.serialize() } + @FeatureFlagMarker + data object DepositFirstUX: FeatureFlag { + override val key: String = "deposit_first_ux_enabled" + override val default: Boolean = false + override val launched: Boolean = false + override val visible: Boolean = true + override val persistLogOut: Boolean = false + } + companion object { val entries: List> get() = FeatureFlagEntries.entries @@ -270,6 +279,7 @@ val FeatureFlag<*>.title: String FeatureFlag.Messenger -> "Messenger" FeatureFlag.NavBar -> "Navigation Bar" FeatureFlag.GiveUsdf -> "Give USDF" + FeatureFlag.DepositFirstUX -> "Deposit First UX" } val FeatureFlag<*>.message: String @@ -296,6 +306,7 @@ val FeatureFlag<*>.message: String FeatureFlag.Messenger -> "When enabled, tapping a contact will open the chat messenger instead of navigating directly to send" FeatureFlag.NavBar -> "Customize the order and labels of navigation bar buttons" FeatureFlag.GiveUsdf -> "When enabled, you'll gain the ability to send USDF directly" + FeatureFlag.DepositFirstUX -> "When enabled, the user experience for new and empty accounts will be centered around depositing funds" } diff --git a/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/app/payments/internal/InternalPurchaseMethodController.kt b/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/app/payments/internal/InternalPurchaseMethodController.kt index 345b6bca6..edfd705a7 100644 --- a/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/app/payments/internal/InternalPurchaseMethodController.kt +++ b/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/app/payments/internal/InternalPurchaseMethodController.kt @@ -43,6 +43,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject import javax.inject.Singleton +import kotlin.time.Duration.Companion.milliseconds @Singleton class InternalPurchaseMethodController @Inject constructor( @@ -133,7 +134,7 @@ class InternalPurchaseMethodController @Inject constructor( } override suspend fun presentDepositOptions(popToRoot: Boolean): AppRoute? { - delay(150) + delay(150.milliseconds) present(PurchaseMethodMetadata( mint = Mint.usdf, purpose = PurchasePurpose.Deposit, diff --git a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/SessionController.kt b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/SessionController.kt index b057f1ae6..59abb44c4 100644 --- a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/SessionController.kt +++ b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/SessionController.kt @@ -46,6 +46,7 @@ data class SessionState( val notificationUnreadCount: Int = 0, val tokens: List = emptyList(), val isPhoneNumberSendEnabled: Boolean = false, + val depositFirstUx: Boolean = false, ) val LocalSessionController = staticCompositionLocalOf { null } \ No newline at end of file diff --git a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/RealSessionController.kt b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/RealSessionController.kt index 2558bd2fd..df9f70273 100644 --- a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/RealSessionController.kt +++ b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/RealSessionController.kt @@ -13,6 +13,7 @@ import com.flipcash.app.core.AppRoute import com.flipcash.app.core.bill.Bill import com.flipcash.app.core.bill.BillState import com.flipcash.app.core.bill.PaymentValuation +import com.flipcash.app.core.extensions.openAsSheet import com.flipcash.app.core.internal.bill.BillController import com.flipcash.app.core.internal.errors.showNetworkError import com.flipcash.app.core.internal.updater.ProfileUpdater @@ -165,9 +166,11 @@ class RealSessionController @Inject constructor( scope.launch { chatCoordinator.reset() } _state.update { SessionState() } } + authState is AuthState.Ready -> { onAppInForeground() } + authState.isAtLeastRegistered -> { updateUserFlags() } @@ -192,6 +195,10 @@ class RealSessionController @Inject constructor( .onEach { autoStart -> _state.update { it.copy(autoStartCamera = autoStart) } } .launchIn(scope) + featureFlagController.observe(FeatureFlag.DepositFirstUX) + .onEach { enabled -> _state.update { it.copy(depositFirstUx = enabled) } } + .launchIn(scope) + featureFlagController.observe(FeatureFlag.VibrateOnScan) .onEach { enabled -> _state.update { it.copy(vibrateOnScan = enabled) } } .launchIn(scope) @@ -318,7 +325,7 @@ class RealSessionController @Inject constructor( // Don't promote during onboarding — the permissions // completion flow sets Ready when navigating to Scanner. flags.isRegistered && !currentState.canAccessAuthenticatedApis - && currentState !is AuthState.Onboarding -> { + && currentState !is AuthState.Onboarding -> { userManager.set(authState = AuthState.Ready) } // Reconcile resume point with freshly-loaded flags. @@ -328,10 +335,12 @@ class RealSessionController @Inject constructor( if (flags.requiresIapForRegistration) AuthState.ResumePoint.AccessKeyThenPurchase else currentState.resumePoint + AuthState.ResumePoint.AccessKeyThenPurchase -> if (!flags.requiresIapForRegistration) AuthState.ResumePoint.PostAccessKey else currentState.resumePoint + AuthState.ResumePoint.AccessKey -> currentState.resumePoint } if (corrected != currentState.resumePoint) { @@ -430,7 +439,8 @@ class RealSessionController @Inject constructor( // Use the state-enriched bill which carries the // nonce from the first presentation, so the // restarted give reuses the same rendezvous. - val currentBill = billController.state.value.bill ?: bill + val currentBill = + billController.state.value.bill ?: bill awaitBillGrab(currentBill, owner) } } @@ -748,7 +758,10 @@ class RealSessionController @Inject constructor( message = "Cash link not provided", type = TraceType.Silent ) - analytics.deeplinkRouted(DeeplinkType.CashLink(), error = IllegalArgumentException("Cash link not provided")) + analytics.deeplinkRouted( + DeeplinkType.CashLink(), + error = IllegalArgumentException("Cash link not provided") + ) return } val owner = userManager.accountCluster @@ -759,7 +772,10 @@ class RealSessionController @Inject constructor( message = "No owner found", type = TraceType.Silent ) - analytics.deeplinkRouted(DeeplinkType.CashLink(), error = IllegalStateException("No owner found")) + analytics.deeplinkRouted( + DeeplinkType.CashLink(), + error = IllegalStateException("No owner found") + ) return } @@ -769,7 +785,10 @@ class RealSessionController @Inject constructor( message = "Cash link empty", type = TraceType.Silent ) - analytics.deeplinkRouted(DeeplinkType.CashLink(), error = IllegalArgumentException("Cash link empty")) + analytics.deeplinkRouted( + DeeplinkType.CashLink(), + error = IllegalArgumentException("Cash link empty") + ) return } @@ -781,9 +800,44 @@ class RealSessionController @Inject constructor( } override fun presentDepositOptions(onRoute: ((AppRoute) -> Unit)?) { - scope.launch { - purchaseMethodController.presentDepositOptions(popToRoot = true)?.let { onRoute?.invoke(it) } + val depositFirstUx = state.value.depositFirstUx + + val navigate = { route: AppRoute -> + onRoute?.invoke(route) + } + + val message = if (depositFirstUx) { + resources.getString(R.string.description_noBalanceYet) + } else { + resources.getString(R.string.description_noBalanceYetDiscover) } + val cta = if (depositFirstUx) { + resources.getString(R.string.action_depositFunds) + } else { + resources.getString(R.string.action_discoverCurrencies) + } + + BottomBarManager.showInfo( + title = resources.getString(R.string.title_noBalanceYet), + message = message, + actions = listOf( + BottomBarAction( + text = cta + ) { + scope.launch { + if (depositFirstUx) { + val destination = purchaseMethodController.presentDepositOptions(popToRoot = true) + if (destination != null) { + navigate(destination) + } + } else { + navigate(AppRoute.Token.Discovery) + } + } + }, + ), + showCancel = true, + ) } private fun claimGiftCard( diff --git a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/TokenInfoViewModel.kt b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/TokenInfoViewModel.kt index 540d1c7b9..993eac6d9 100644 --- a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/TokenInfoViewModel.kt +++ b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/TokenInfoViewModel.kt @@ -68,6 +68,7 @@ class TokenInfoViewModel @Inject constructor( val descriptionExpanded: Boolean = false, val historicalMarketCapData: Map>> = emptyMap(), val selectedPeriod: Period = Period.All, + val canGiveUsdf: Boolean = false, ) { val canSell: Boolean get() = balance.underlyingTokenAmount.valueNonZero() @@ -77,6 +78,7 @@ class TokenInfoViewModel @Inject constructor( } sealed interface Event { + data class CanGiveUsdf(val enabled: Boolean): Event data class MarketCapChartEnabled(val enabled: Boolean) : Event data class OnMintProvided(val mint: Mint, val shortFall: Fiat? = null) : Event data class OnTokenChanged(val token: Loadable, val shortFall: Fiat? = null) : Event @@ -102,6 +104,11 @@ class TokenInfoViewModel @Inject constructor( } init { + features.observe(FeatureFlag.GiveUsdf) + .onEach { + dispatchEvent(Event.CanGiveUsdf(it)) + }.launchIn(viewModelScope) + features.observe(FeatureFlag.MarketCapChart) .onEach { dispatchEvent(Event.MarketCapChartEnabled(it)) @@ -276,7 +283,13 @@ class TokenInfoViewModel @Inject constructor( eventFlow .filterIsInstance() - .mapNotNull { purchaseMethodController.presentDepositOptions(popToRoot = true) } + .mapNotNull { + val depositFirstUx = features.get(FeatureFlag.DepositFirstUX) + if (!depositFirstUx) { + return@mapNotNull AppRoute.Transfers.Deposit(showOtherOptions = false) + } + + purchaseMethodController.presentDepositOptions(popToRoot = true) } .onEach { route -> dispatchEvent(Event.OpenScreen(route)) } .launchIn(viewModelScope) @@ -314,6 +327,7 @@ class TokenInfoViewModel @Inject constructor( is Event.LoadHistoricalDataForPeriod -> { state -> state } is Event.Share -> { state -> state } is Event.Exit -> { state -> state } + is Event.CanGiveUsdf -> { state -> state.copy(canGiveUsdf = event.enabled) } } } }