From b21a2a39e2547ff6f5aa9697607e3cd66b72c2b6 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 20 Aug 2026 17:23:54 -0400 Subject: [PATCH 1/2] chore(ui): tear out the v1 UI behind the newUI flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `FeatureFlag.NewUi` launched, so every `isNewUi` branch had exactly one live side. This removes the flag and collapses those branches, then deletes the v1 surfaces that are stranded once the branch is gone. Removed - `FeatureFlag.NewUi` and `FeatureFlag.NavBar` (the v1 nav-bar config flag), plus the Labs "Nav Bar" settings screen that edited it. - The v1 scanner chrome: `DecorView`, `ScannerDecorItem`, `ScannerNavigationBar`, `LongPressDraggable`, `NavBarConfig`, `GiveButtonLabel`. - `apps/flipcash/features/home` — the v1 home host, unreferenced once the nav bar is hoisted to the app root. Dropped from `settings.gradle.kts`. - The v1 balance surface (`BalanceScreen`, `BalanceScreenContent`, `CashReservesRow`) and the v1 currency-info sections (`TokenBalance`, `TokenDetails`), superseded by the wallet deck and `CurrencyInfoContentV2`. - `CurrencyCreatorUpsellCard` — the creator promo lives on the wallet now. UI tests The Maestro suite still drove v1 affordances (a "Tips"/"Discover"/"Cash" tab row, a `menu_button`, a swipe-down sheet dismissal), so it is retargeted to the v2 tab model rather than left to fail: - `NavigationBar` tabs are tagged `nav_scanner` / `nav_wallet` / `nav_chats` / `nav_tipcard`; `helpers/close_open_sheet.yaml` unwinds to the scanner tab. - `SwapStep.Entry` is explicitly tagged `swap_screen` (the derived id would be `entry_screen`); `buy.yaml` / `sell.yaml` already anchored on `swap_screen`. - `CurrencyInfoExpansion` carries the `token_info_screen` anchor too, since in v2 a wallet card expands as an overlay instead of pushing the screen. - The chats list is tagged `chat_list`. - `buy.yaml`/`sell.yaml` follow v2 swap semantics ("Get" on a currency you do not hold, "Convert" in place of "Sell"), give flows enter via Wallet → currency → Give, and `BaselineProfileGenerator` journeys switch tabs instead of tapping v1 labels. --- .../com/flipcash/app/internal/ui/App.kt | 144 ++---- .../app/internal/ui/AppNavigationBar.kt | 18 +- .../app/internal/ui/navigation/AppContent.kt | 104 +---- .../ui/navigation/AppScreenContent.kt | 21 +- .../app/internal/ui/navigation/MainRoot.kt | 43 +- .../navigation/BuildNavGraphForLaunchTest.kt | 123 ++---- .../benchmark/BaselineProfileGenerator.kt | 133 +++--- .../app/core/ui/LongPressDraggable.kt | 128 ------ .../com/flipcash/app/core/ui/NavigationBar.kt | 362 +-------------- .../kotlin/com/flipcash/app/core/AppRoute.kt | 12 +- .../app/core/extensions/CodeNavigator.kt | 42 +- .../app/core/navigation/GiveButtonLabel.kt | 9 - .../flipcash/app/core/navigation/HomeRoute.kt | 11 +- .../app/core/navigation/NavBarButton.kt | 7 +- .../app/core/navigation/NavBarConfig.kt | 54 --- .../app/core/navigation/NavBarRoutes.kt | 10 +- .../app/core/withdrawal/WithdrawalStep.kt | 3 +- .../core/src/main/res/values/strings.xml | 9 - .../app/core/navigation/HomeRouteTest.kt | 17 +- .../app/core/navigation/NavBarConfigTest.kt | 76 ---- .../app/core/navigation/NavBarRoutesTest.kt | 10 +- .../com/flipcash/app/balance/BalanceScreen.kt | 69 --- .../balance/internal/BalanceScreenContent.kt | 186 -------- .../balance/internal/WalletScreenContent.kt | 2 +- .../internal/components/CashReservesRow.kt | 60 --- .../internal/BalanceScreenContentTest.kt | 76 ---- .../com/flipcash/app/cash/CashScreen.kt | 10 +- .../app/discovery/TokenDiscoveryScreen.kt | 6 - .../internal/TokenDiscoveryViewModel.kt | 2 - .../internal/components/TokenLeaderboard.kt | 33 +- apps/flipcash/features/home/.gitignore | 2 - apps/flipcash/features/home/build.gradle.kts | 21 - .../com/flipcash/app/home/HomeScreen.kt | 47 -- .../flipcash/app/lab/NavBarSettingsScreen.kt | 30 -- .../app/lab/internal/LabsScreenContent.kt | 41 +- .../app/lab/internal/NavBarSettingsContent.kt | 88 ---- .../app/login/OnboardingFlowScreen.kt | 9 +- .../app/menu/internal/MenuScreenContent.kt | 105 +---- .../flipcash/app/scanner/internal/Scanner.kt | 69 +-- .../app/scanner/internal/ScannerDecorItem.kt | 14 - .../internal/bills/ScannableContainer.kt | 35 +- .../internal/ui/components/DecorView.kt | 192 -------- .../ui/components/ScannerNavigationBar.kt | 66 --- .../com/flipcash/app/tipping/TipCardScreen.kt | 116 +---- .../com/flipcash/app/tipping/TipsScreen.kt | 74 +--- .../app/tokens/CurrencyInfoExpansion.kt | 6 +- .../com/flipcash/app/tokens/SwapFlowScreen.kt | 4 +- .../flipcash/app/tokens/TokenInfoScreen.kt | 132 +++--- .../app/tokens/internal/TokenInfoScreen.kt | 414 +----------------- .../components/info/CurrencyInfoContentV2.kt | 5 +- .../internal/components/info/TokenBalance.kt | 58 --- .../internal/components/info/TokenDetails.kt | 129 ------ .../app/withdrawal/WithdrawalFlowScreen.kt | 25 +- .../UsdcWithdrawalInformationScreen.kt | 51 +-- .../screens/WithdrawalDestinationScreen.kt | 13 +- .../internal/screens/WithdrawalEntryScreen.kt | 13 +- .../activityfeed/ActivityFeedCoordinator.kt | 2 +- .../app/activityfeed/ActivityFeedUpdater.kt | 2 +- .../app/bills/decor/TipCardDecorator.kt | 7 +- .../flipcash/app/featureflags/FeatureFlag.kt | 29 -- .../flipcash/app/funding/internal/Buttons.kt | 152 +------ .../InternalPurchaseMethodController.kt | 15 +- .../app/router/internal/NavigateToTest.kt | 52 +-- .../app/router/internal/ResolveRoutesTest.kt | 95 ++-- .../session/internal/RealSessionController.kt | 9 +- .../flipcash/app/tokens/TokenCoordinator.kt | 18 +- .../tokens/ui/CurrencyCreatorUpsellCard.kt | 144 ------ .../app/tokens/ui/SelectTokenViewModel.kt | 32 +- .../flipcash/app/tokens/ui/SwapViewModel.kt | 25 +- .../app/tokens/ui/SwapViewModelErrorTest.kt | 11 +- maestro/README.md | 32 +- maestro/blocking.yaml | 6 +- maestro/buy.yaml | 26 +- maestro/currency_creator.yaml | 6 +- maestro/discovery_leaderboard.yaml | 6 +- maestro/helpers/close_open_sheet.yaml | 41 +- maestro/open_token_info_deeplink.yaml | 25 +- maestro/screenshots/groups/03_menu.yaml | 14 - maestro/screenshots/groups/06_give.yaml | 22 +- maestro/sell.yaml | 14 +- maestro/show_bill_and_put_back_in_wallet.yaml | 4 +- maestro/subflows/navigate_to_discovery.yaml | 9 + maestro/subflows/navigate_to_give.yaml | 6 +- maestro/subflows/navigate_to_menu.yaml | 3 +- maestro/subflows/navigate_to_wallet.yaml | 3 +- maestro/subflows/open_token_info.yaml | 3 +- maestro/subflows/pull_out_bill.yaml | 6 +- maestro/subflows/return_to_scanner.yaml | 7 +- maestro/tip_chat.yaml | 4 +- maestro/tipping_setup.yaml | 6 +- maestro/usdf_only_gate.yaml | 20 +- settings.gradle.kts | 1 - 92 files changed, 630 insertions(+), 3766 deletions(-) delete mode 100644 apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/LongPressDraggable.kt delete mode 100644 apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/GiveButtonLabel.kt delete mode 100644 apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/NavBarConfig.kt delete mode 100644 apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/navigation/NavBarConfigTest.kt delete mode 100644 apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/BalanceScreen.kt delete mode 100644 apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/BalanceScreenContent.kt delete mode 100644 apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/components/CashReservesRow.kt delete mode 100644 apps/flipcash/features/balance/src/test/kotlin/com/flipcash/app/balance/internal/BalanceScreenContentTest.kt delete mode 100644 apps/flipcash/features/home/.gitignore delete mode 100644 apps/flipcash/features/home/build.gradle.kts delete mode 100644 apps/flipcash/features/home/src/main/kotlin/com/flipcash/app/home/HomeScreen.kt delete mode 100644 apps/flipcash/features/lab/src/main/kotlin/com/flipcash/app/lab/NavBarSettingsScreen.kt delete mode 100644 apps/flipcash/features/lab/src/main/kotlin/com/flipcash/app/lab/internal/NavBarSettingsContent.kt delete mode 100644 apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/ScannerDecorItem.kt delete mode 100644 apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/ui/components/DecorView.kt delete mode 100644 apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/ui/components/ScannerNavigationBar.kt delete mode 100644 apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/components/info/TokenBalance.kt delete mode 100644 apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/components/info/TokenDetails.kt delete mode 100644 apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/CurrencyCreatorUpsellCard.kt create mode 100644 maestro/subflows/navigate_to_discovery.yaml diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/App.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/App.kt index f9285950b4..35f3ecda01 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/App.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/App.kt @@ -40,15 +40,12 @@ import com.flipcash.app.core.LocalUserManager import com.flipcash.app.core.extensions.navigateAll import com.flipcash.app.core.navigation.DeeplinkAction import com.flipcash.app.core.navigation.NavBarButton -import com.flipcash.app.core.navigation.NavBarConfig import com.flipcash.app.core.ui.NavigationBar -import com.flipcash.app.core.ui.rememberNavigationBarState import com.flipcash.app.core.verification.email.LocalEmailCodeChannel import com.flipcash.app.featureflags.FeatureFlag import com.flipcash.app.featureflags.LocalFeatureFlags import com.flipcash.app.featureflags.model.BackgroundResetTimeout import com.flipcash.app.internal.ui.navigation.AppContent -import com.flipcash.app.internal.ui.navigation.NewAppContent import com.flipcash.app.internal.ui.navigation.appEntryProvider import com.flipcash.app.internal.ui.navigation.decorators.rememberNavBlockingOverlayEntryDecorator import com.flipcash.app.internal.ui.navigation.decorators.rememberNavMessagingEntryDecorator @@ -87,7 +84,6 @@ import kotlinx.coroutines.flow.first internal fun App( tipsEngine: TipsEngine, ) { - val features = LocalFeatureFlags.current val router = LocalRouter.current!! val analytics = rememberAnalytics() val viewModel = getActivityScopedViewModel() @@ -118,12 +114,10 @@ internal fun App( val session = LocalSessionController.current!! val userState by userManager.state.collectAsStateWithLifecycle() - val isNewUi by features.observe(FeatureFlag.NewUi).collectAsStateWithLifecycle() - - // Card-expand (iOS #587) is owned HERE rather than inside NewAppContent because the deeplink - // handling below sits outside the v1/v2 shells: a `/token` link opens the wallet's expanded card - // (see DeeplinkAction.OpenToken), which needs the controller. NewAppContent provides it to the - // tree; v1 has no expansion and never touches it. + // Card-expand (iOS #587) is owned HERE rather than inside AppContent because the deeplink + // handling below sits outside the shell: a `/token` link opens the wallet's expanded card + // (see DeeplinkAction.OpenToken), which needs the controller. AppContent provides it to the + // tree. val context = LocalContext.current val cardExpansion = remember(context) { CardExpansionController().apply { @@ -179,75 +173,42 @@ internal fun App( LocalSharedTransitionScope provides this, ) { CoinbaseOnRampHandler { - if (isNewUi) { - NewAppContent( - codeNavigator = codeNavigator, - resultStateRegistry = resultStateRegistry, - barManager = barManager, - cardExpansion = cardExpansion, - deepLink = { deepLink }, - onPendingAction = { action -> - deeplinkHandled = true - when (action) { - // v2 cold start: the wallet is already the - // launch home, so the token just opens as its - // expanded card on top of it. - is DeeplinkAction.OpenToken -> - cardExpansion.beginExpanded(action.mint) - is DeeplinkAction.OpenCashLink -> - session.openCashLink(action.entropy) - is DeeplinkAction.PresentTipCard -> - session.resolveTipCard(action.userId) - is DeeplinkAction.Login -> - viewModel.handleLoginEntropy( - action.entropy, - onSwitchAccount = { - codeNavigator.replaceAll( - AppRoute.OnboardingFlow( - seed = action.entropy, - fromDeeplink = true - ) - ) - }, - onDismissed = { } - ) - else -> {} - } - deepLink = null - } - ) - } else { - AppContent( - codeNavigator = codeNavigator, - resultStateRegistry = resultStateRegistry, - barManager = barManager, - deepLink = { deepLink }, - onPendingAction = { action -> - deeplinkHandled = true - when (action) { - is DeeplinkAction.OpenCashLink -> - session.openCashLink(action.entropy) - is DeeplinkAction.PresentTipCard -> - session.resolveTipCard(action.userId) - is DeeplinkAction.Login -> - viewModel.handleLoginEntropy( - action.entropy, - onSwitchAccount = { - codeNavigator.replaceAll( - AppRoute.OnboardingFlow( - seed = action.entropy, - fromDeeplink = true - ) + AppContent( + codeNavigator = codeNavigator, + resultStateRegistry = resultStateRegistry, + barManager = barManager, + cardExpansion = cardExpansion, + deepLink = { deepLink }, + onPendingAction = { action -> + deeplinkHandled = true + when (action) { + // Cold start: the wallet is already the launch + // home, so the token just opens as its expanded + // card on top of it. + is DeeplinkAction.OpenToken -> + cardExpansion.beginExpanded(action.mint) + is DeeplinkAction.OpenCashLink -> + session.openCashLink(action.entropy) + is DeeplinkAction.PresentTipCard -> + session.resolveTipCard(action.userId) + is DeeplinkAction.Login -> + viewModel.handleLoginEntropy( + action.entropy, + onSwitchAccount = { + codeNavigator.replaceAll( + AppRoute.OnboardingFlow( + seed = action.entropy, + fromDeeplink = true ) - }, - onDismissed = { } - ) - else -> {} - } - deepLink = null + ) + }, + onDismissed = { } + ) + else -> {} } - ) - } + deepLink = null + } + ) // The scrim + bill overlay are hosted per-entry by // NavBillOverlayEntryDecorator (added to the AppNavHost @@ -285,30 +246,19 @@ internal fun App( } else false if (!delivered) { - codeNavigator.navigateAll( - action.routes, - isNewUi = isNewUi, - ) + codeNavigator.navigateAll(action.routes) } } is DeeplinkAction.OpenToken -> { - if (isNewUi) { - // Land on the wallet tab (clearing anything pushed on - // it) and open the token as its EXPANDED CARD — the - // same overlay, chrome and dismissal a tap on the card - // gives. Pushing it instead reads as a modal on a stack - // the user never navigated. Mirrors iOS - // DeepLinkController's requestedCardMint. - codeNavigator.navigateAll( - listOf(AppRoute.Sheets.Wallet), - isNewUi = true, - ) - cardExpansion.beginExpanded(action.mint) - } else { - // v1 has no card expansion — open the wallet sheet. - codeNavigator.navigateAll(action.routes) - } + // Land on the wallet tab (clearing anything pushed on + // it) and open the token as its EXPANDED CARD — the + // same overlay, chrome and dismissal a tap on the card + // gives. Pushing it instead reads as a modal on a stack + // the user never navigated. Mirrors iOS + // DeepLinkController's requestedCardMint. + codeNavigator.navigateAll(listOf(AppRoute.Sheets.Wallet)) + cardExpansion.beginExpanded(action.mint) } is DeeplinkAction.Login -> viewModel.handleLoginEntropy( diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/AppNavigationBar.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/AppNavigationBar.kt index c0b36d0c91..96f4c2d498 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/AppNavigationBar.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/AppNavigationBar.kt @@ -19,13 +19,10 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flipcash.app.core.AppRoute import dev.chrisbanes.haze.HazeState import com.flipcash.app.core.navigation.NavBarButton -import com.flipcash.app.core.navigation.NavBarConfig import com.flipcash.app.core.navigation.asNavBarTab import com.flipcash.app.core.navigation.destinationRoute import com.flipcash.app.core.ui.NavigationBar import com.flipcash.app.core.ui.rememberNavigationBarState -import com.flipcash.app.featureflags.FeatureFlag -import com.flipcash.app.featureflags.LocalFeatureFlags import com.flipcash.app.session.LocalSessionController import com.getcode.manager.BottomBarManager import com.getcode.navigation.core.CodeNavigator @@ -34,12 +31,11 @@ import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map /** - * The hoisted v2 navigation bar — root chrome, not owned by any screen. It renders over whichever + * The hoisted navigation bar — root chrome, not owned by any screen. It renders over whichever * top-level route is a tab home and switches tabs by **swapping the current screen** (single * backstack, like a tab bar — hence [CodeNavigator.replaceAll], not a sheet). * - * Only visible when [FeatureFlag.NewUi] is on and the current route maps to a tab; v1 keeps its - * in-screen bar (see ScannerNavigationBar). When v1 is dropped, this becomes the only nav bar. + * Only visible when the current route maps to a tab. * * Self-positions as a full-size, touch-transparent overlay pinned to the bottom, so it can be * dropped into any container (it does not require a BoxScope from its caller). @@ -57,7 +53,7 @@ internal fun AppNavigationBar( // A BottomBar modal (e.g. Add Money) renders in the nav content, above this bar; hide the bar so // it doesn't draw over the modal. Safe because the bar is a bottom overlay (not a scaffold - // bottomBar), so hiding it doesn't resize the content beneath (see NewAppContent). + // bottomBar), so hiding it doesn't resize the content beneath (see AppContent). val bottomBarMessages by BottomBarManager.messages.collectAsStateWithLifecycle() // A bill/tip card renders at the app root above everything; hide the bar so it doesn't show @@ -67,8 +63,8 @@ internal fun AppNavigationBar( session?.billState?.map { it.bill != null } ?: flowOf(false) }.collectAsStateWithLifecycle(initialValue = false) - // Unread tip-DM count, badged onto the Chat tab — the same source the v1 scanner bar badges its - // Tips button with, so the badge appears, updates and clears as conversations are read. + // Unread tip-DM count, badged onto the Chat tab, so the badge appears, updates and clears as + // conversations are read. val tipUnreadCount by remember(session) { session?.state?.map { it.tipsUnreadCount } ?: flowOf(0) }.collectAsStateWithLifecycle(initialValue = 0) @@ -84,8 +80,6 @@ internal fun AppNavigationBar( exit = slideOutVertically { it } + fadeOut(), ) { val state = rememberNavigationBarState( - isNewUi = true, - config = NavBarConfig(order = NavBarButton.v2Order), selectedTab = selectedTab ?: NavBarButton.Wallet, tipUnreadCount = tipUnreadCount, ) @@ -97,7 +91,7 @@ internal fun AppNavigationBar( state = state, onButtonClick = { button -> // Tab bar semantics: swap the current screen (single backstack). - button.destinationRoute()?.let(navigator::replaceAll) + navigator.replaceAll(button.destinationRoute()) }, hazeState = hazeState, ) diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppContent.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppContent.kt index 173d38db0a..3ca519b60f 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppContent.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppContent.kt @@ -57,7 +57,7 @@ private fun isTokenInfoKey(key: Any?): Boolean { } /** - * True when a scene key belongs to [AppRoute.Sheets.Give] (the v2 give/cash screen, which is pushed + * True when a scene key belongs to [AppRoute.Sheets.Give] (the give/cash screen, which is pushed * rather than presented as a sheet). Same stringified-key match as [isTokenInfoKey]. */ private fun isGiveKey(key: Any?): Boolean = @@ -65,103 +65,6 @@ private fun isGiveKey(key: Any?): Boolean = @Composable internal fun AppContent( - codeNavigator: CodeNavigator, - resultStateRegistry: NavResultStateRegistry, - barManager: BarManager, - deepLink: () -> DeepLink?, - onPendingAction: (DeeplinkAction) -> Unit = {}, -) { - AppNavHost( - navigator = codeNavigator, - resultStateRegistry = resultStateRegistry, - decorators = listOf( - // First = outermost decorator overlay: the bill draws above the screen content (as it - // did at the app root). It's skipped for sheet entries, and NavDisplay paints the sheet - // scene above the base entry — so sheets open over the bill. See the decorator's docs. - rememberNavBillOverlayEntryDecorator(), - rememberNavMessagingEntryDecorator( - codeNavigator.backStack, - barManager - ), - rememberNavBlockingOverlayEntryDecorator(), - ), - sceneStrategies = listOf( - ModalBottomSheetSceneStrategy( - codeNavigator.resultStore - ) { - codeNavigator.backStack.getOrNull( - codeNavigator.backStack.lastIndex - 1 - ) - }, - SinglePaneSceneStrategy(), - ), - transitionSpec = { - val shouldCrossfade = - initialState.key == AppRoute.Loading.toString() || - targetState.key == AppRoute.Loading.toString() || - targetState.key.toString() - .startsWith("Login") - when { - shouldCrossfade -> fadeIn(tween(300)) togetherWith fadeOut( - tween(300) - ) - - targetState is OverlayScene<*> || initialState is OverlayScene<*> -> - EnterTransition.None togetherWith ExitTransition.None - - else -> slideInHorizontally(initialOffsetX = { it }) togetherWith - slideOutHorizontally(targetOffsetX = { -it }) - } - }, - popTransitionSpec = { - val shouldCrossfade = - initialState.key == AppRoute.Loading.toString() || - targetState.key == AppRoute.Loading.toString() || - targetState.key.toString() - .startsWith("Login") - when { - shouldCrossfade -> fadeIn(tween(300)) togetherWith fadeOut( - tween(300) - ) - - targetState is OverlayScene<*> || initialState is OverlayScene<*> -> - EnterTransition.None togetherWith ExitTransition.None - - else -> slideInHorizontally(initialOffsetX = { -it }) togetherWith - slideOutHorizontally(targetOffsetX = { it }) - } - }, - predictivePopTransitionSpec = { - val shouldCrossfade = - initialState.key == AppRoute.Loading.toString() || - targetState.key == AppRoute.Loading.toString() || - targetState.key.toString() - .startsWith("Login") - when { - shouldCrossfade -> fadeIn(tween(300)) togetherWith fadeOut( - tween(300) - ) - - targetState is OverlayScene<*> || initialState is OverlayScene<*> -> - EnterTransition.None togetherWith ExitTransition.None - - else -> slideInHorizontally(initialOffsetX = { -it }) togetherWith - slideOutHorizontally(targetOffsetX = { it }) - } - }, - onBack = { codeNavigator.navigateBack() }, - entryProvider = appEntryProvider( - isNewUi = false, - resultStateRegistry = resultStateRegistry, - barManager = barManager, - deepLink = deepLink, - onPendingAction = onPendingAction, - ), - ) -} - -@Composable -internal fun NewAppContent( codeNavigator: CodeNavigator, resultStateRegistry: NavResultStateRegistry, barManager: BarManager, @@ -169,7 +72,7 @@ internal fun NewAppContent( deepLink: () -> DeepLink?, onPendingAction: (DeeplinkAction) -> Unit = {}, ) { - // The v2 nav bar is a single persistent overlay at the app root (below), so tab switches stay + // The nav bar is a single persistent overlay at the app root (below), so tab switches stay // seamless — one instance, sliding selection pill, one haze source. It's a bottom OVERLAY over the // full-height nav content, so hiding it for a modal/bill never resizes the content. Space for it is // reserved PER ENTRY by NavTabBarInsetEntryDecorator (tab homes only), which keeps the inset stable @@ -214,7 +117,7 @@ internal fun NewAppContent( }, SinglePaneSceneStrategy(), ), - // v2 is tab-centric: a forward move that LANDS on a tab home is a tab switch + // Navigation is tab-centric: a forward move that LANDS on a tab home is a tab switch // (replaceAll between tab homes) and crossfades; any other forward move is a push // into a detail screen and slides in. Pops always slide back out (a pop is always // leaving a detail). Sheets/overlays keep their own (no) transition. @@ -268,7 +171,6 @@ internal fun NewAppContent( }, onBack = { codeNavigator.navigateBack() }, entryProvider = appEntryProvider( - isNewUi = true, resultStateRegistry = resultStateRegistry, barManager = barManager, deepLink = deepLink, diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt index 2e6b295480..37c6256e4e 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt @@ -22,7 +22,6 @@ import com.flipcash.app.advanced.AdvancedFeaturesScreen import com.flipcash.app.appsettings.AppSettingsScreen import com.flipcash.app.devicelogs.DeviceLogsScreen import com.flipcash.app.backupkey.BackupKeyScreen -import com.flipcash.app.balance.BalanceScreen import com.flipcash.app.balance.WalletScreen import com.flipcash.app.cash.CashScreen import com.flipcash.app.contact.verification.VerificationFlowScreen @@ -39,7 +38,6 @@ import com.flipcash.app.messenger.ChatFlowScreen import com.flipcash.app.discovery.TokenDiscoveryScreen import com.flipcash.app.internal.ui.navigation.decorators.rememberNavMessagingEntryDecorator import com.flipcash.app.lab.LabsScreen -import com.flipcash.app.lab.NavBarSettingsScreen import com.flipcash.app.login.OnboardingFlowScreen import com.flipcash.app.menu.MenuScreen import com.flipcash.app.myaccount.BlocklistScreen @@ -69,7 +67,6 @@ import com.getcode.ui.components.bars.BarManager import dev.theolm.rinku.DeepLink fun appEntryProvider( - isNewUi: Boolean, resultStateRegistry: NavResultStateRegistry, barManager: BarManager, deepLink: () -> DeepLink?, @@ -77,7 +74,7 @@ fun appEntryProvider( ): (NavKey) -> NavEntry = entryProvider { // Loading / splash - annotatedEntry { MainRoot(isNewUi, deepLink, onPendingAction) } + annotatedEntry { MainRoot(deepLink, onPendingAction) } // Onboarding flow annotatedEntry { key -> @@ -86,7 +83,7 @@ fun appEntryProvider( // Main annotatedEntry { key -> - SheetContent(key, isNewUi, resultStateRegistry, barManager) + SheetContent(key, resultStateRegistry, barManager) } annotatedEntry { key -> AppRestrictedScreen(key.restrictionType) } annotatedEntry { ScannerScreen() } @@ -102,13 +99,9 @@ fun appEntryProvider( annotatedEntry { key -> TokenSelectScreen(key.purpose) } annotatedEntry { TipAmountEntryScreen() } annotatedEntry { - if (isNewUi) { - // v2 wallet hosts the card-expand overlay in-entry so a pushed action (Give/Convert/Withdraw) - // covers the expanded currency-info with correct z-order (iOS WalletScreen structure). - CardExpandHost { WalletScreen() } - } else { - BalanceScreen() - } + // The wallet hosts the card-expand overlay in-entry so a pushed action (Give/Convert/Withdraw) + // covers the expanded currency-info with correct z-order (iOS WalletScreen structure). + CardExpandHost { WalletScreen() } } annotatedEntry { ShareAppScreen() } annotatedEntry { ActivityHistoryScreen() } @@ -148,7 +141,6 @@ fun appEntryProvider( // Menu annotatedEntry { AppSettingsScreen() } annotatedEntry { key -> LabsScreen(onboarding = key.onboarding) } - annotatedEntry { NavBarSettingsScreen() } annotatedEntry { UserProfileScreen() } annotatedEntry { MyAccountScreen() } annotatedEntry { BlocklistScreen() } @@ -174,7 +166,6 @@ fun appEntryProvider( @Composable private fun SheetContent( key: AppRoute.Main.Sheet, - isNewUi: Boolean, resultStateRegistry: NavResultStateRegistry, barManager: BarManager, ) { @@ -256,7 +247,7 @@ private fun SheetContent( } }, onBack = { onBack() }, - entryProvider = appEntryProvider(isNewUi, resultStateRegistry, barManager, deepLink = { null }), + entryProvider = appEntryProvider(resultStateRegistry, barManager, deepLink = { null }), ) BackHandler { onBack() } diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/MainRoot.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/MainRoot.kt index c1894a8527..95705f0458 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/MainRoot.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/MainRoot.kt @@ -28,7 +28,6 @@ import com.flipcash.app.core.navigation.DeeplinkAction import com.flipcash.app.core.navigation.homeRoute import com.flipcash.app.core.extensions.navigateAll import com.flipcash.app.core.extensions.resolveBackStack -import com.flipcash.app.featureflags.LocalFeatureFlags import com.flipcash.app.router.LocalRouter import com.flipcash.app.router.Router import com.flipcash.services.user.AuthState @@ -47,7 +46,6 @@ import kotlin.time.Duration.Companion.seconds @Composable internal fun MainRoot( - isNewUi: Boolean, deepLink: () -> DeepLink?, onPendingAction: (DeeplinkAction) -> Unit = {}, ) { @@ -105,7 +103,6 @@ internal fun MainRoot( ) val launch = buildNavGraphForLaunch( state = state, - isNewUi = isNewUi, router = router, deepLink = deepLink ) @@ -133,7 +130,7 @@ internal fun MainRoot( if (!current.startsWith(target)) { navigator.replaceAll(launch.baseRoutes) if (launch.deeplinkRoutes.isNotEmpty()) { - navigator.navigateAll(launch.deeplinkRoutes, isNewUi = isNewUi) + navigator.navigateAll(launch.deeplinkRoutes) } } @@ -159,17 +156,15 @@ internal data class LaunchNavGraph( val baseRoutes: List, val deeplinkRoutes: List = emptyList(), val pendingAction: DeeplinkAction? = null, - /** v2 (tab-centric) shell. Changes how [deeplinkRoutes] resolve — see [resolveBackStack]. */ - val isNewUi: Boolean = false, ) { /** * Predict the final backstack that [baseRoutes] + `navigateAll(deeplinkRoutes)` will produce. - * Uses the shared [resolveBackStack] so it applies the same sheet-wrapping (v1) or tab-switch - * (v2) as `navigateAll`, letting us compare against the current backstack and skip redundant + * Uses the shared [resolveBackStack] so it applies the same tab-switch resolution as + * `navigateAll`, letting us compare against the current backstack and skip redundant * navigation. */ fun resolvedBackStack(): List = - resolveBackStack(baseRoutes, deeplinkRoutes, isNewUi) + resolveBackStack(baseRoutes, deeplinkRoutes) } /** @@ -188,7 +183,6 @@ private fun List.startsWith(prefix: List): Boolean { internal fun buildNavGraphForLaunch( state: AuthState, router: Router, - isNewUi: Boolean, deepLink: () -> DeepLink?, ): LaunchNavGraph? { return when (state) { @@ -219,46 +213,29 @@ internal fun buildNavGraphForLaunch( } AuthState.Ready -> { - // New UI opens on the Wallet tab; v1 opens on the Scanner. - val home = homeRoute(isNewUi) + val home = homeRoute val link = deepLink() if (link != null) { when (val action = router.dispatch(link)) { is DeeplinkAction.Navigate -> LaunchNavGraph( baseRoutes = listOf(home), deeplinkRoutes = action.routes, - isNewUi = isNewUi, ) - // v2 opens a token link as the wallet's expanded card (a pending action applied - // on top of the wallet home, which is already the launch base); v1 has no card - // expansion, so it takes the route form — the wallet sheet with token info inside. - is DeeplinkAction.OpenToken -> if (isNewUi) { - LaunchNavGraph( - baseRoutes = listOf(home), - pendingAction = action, - isNewUi = true, - ) - } else { - LaunchNavGraph( - baseRoutes = listOf(home), - deeplinkRoutes = action.routes, - isNewUi = false, - ) - } - + // A token link opens as the wallet's expanded card — a pending action applied on + // top of the wallet home, which is already the launch base. + is DeeplinkAction.OpenToken, is DeeplinkAction.OpenCashLink, is DeeplinkAction.PresentTipCard, is DeeplinkAction.Login -> LaunchNavGraph( baseRoutes = listOf(home), pendingAction = action, - isNewUi = isNewUi, ) - else -> LaunchNavGraph(listOf(home), isNewUi = isNewUi) + else -> LaunchNavGraph(listOf(home)) } } else { - LaunchNavGraph(listOf(home), isNewUi = isNewUi) + LaunchNavGraph(listOf(home)) } } diff --git a/apps/flipcash/app/src/test/kotlin/com/flipcash/app/internal/ui/navigation/BuildNavGraphForLaunchTest.kt b/apps/flipcash/app/src/test/kotlin/com/flipcash/app/internal/ui/navigation/BuildNavGraphForLaunchTest.kt index 7b0624c01d..9a691e9d62 100644 --- a/apps/flipcash/app/src/test/kotlin/com/flipcash/app/internal/ui/navigation/BuildNavGraphForLaunchTest.kt +++ b/apps/flipcash/app/src/test/kotlin/com/flipcash/app/internal/ui/navigation/BuildNavGraphForLaunchTest.kt @@ -35,44 +35,40 @@ class BuildNavGraphForLaunchTest { state: AuthState, action: DeeplinkAction = DeeplinkAction.None, deepLink: DeepLink? = null, - isNewUi: Boolean = false, ): LaunchNavGraph? = buildNavGraphForLaunch( state = state, router = FakeRouter(action), - isNewUi = isNewUi, deepLink = { deepLink }, ) + private fun buildReady( + action: DeeplinkAction = DeeplinkAction.None, + deepLink: DeepLink? = dummyLink, + ) = build(AuthState.Ready, action, deepLink)!! + // -- Ready -- @Test - fun `logged in without deeplink navigates to Scanner`() { + fun `logged in without deeplink opens on the Wallet tab`() { val result = build(AuthState.Ready)!! - assertEquals(listOf(AppRoute.Main.Scanner), result.baseRoutes) + assertEquals(listOf(AppRoute.Sheets.Wallet), result.baseRoutes) assertTrue(result.deeplinkRoutes.isEmpty()) + assertEquals(listOf(AppRoute.Sheets.Wallet), result.resolvedBackStack()) } @Test fun `logged in with Navigate deeplink includes deeplink routes`() { val routes = listOf(AppRoute.Main.Scanner) - val result = build( - state = AuthState.Ready, - action = DeeplinkAction.Navigate(routes), - deepLink = dummyLink, - )!! - assertEquals(listOf(AppRoute.Main.Scanner), result.baseRoutes) + val result = buildReady(DeeplinkAction.Navigate(routes)) + assertEquals(listOf(AppRoute.Sheets.Wallet), result.baseRoutes) assertEquals(routes, result.deeplinkRoutes) } @Test fun `logged in with OpenCashLink fires eagerly via pendingAction`() { val action = DeeplinkAction.OpenCashLink("testEntropy") - val result = build( - state = AuthState.Ready, - action = action, - deepLink = dummyLink, - )!! - assertEquals(listOf(AppRoute.Main.Scanner), result.baseRoutes) + val result = buildReady(action) + assertEquals(listOf(AppRoute.Sheets.Wallet), result.baseRoutes) assertTrue(result.deeplinkRoutes.isEmpty()) assertEquals(action, result.pendingAction) } @@ -80,59 +76,30 @@ class BuildNavGraphForLaunchTest { @Test fun `logged in with Login action fires eagerly via pendingAction`() { val action = DeeplinkAction.Login("seed") - val result = build( - state = AuthState.Ready, - action = action, - deepLink = dummyLink, - )!! - assertEquals(listOf(AppRoute.Main.Scanner), result.baseRoutes) + val result = buildReady(action) + assertEquals(listOf(AppRoute.Sheets.Wallet), result.baseRoutes) assertTrue(result.deeplinkRoutes.isEmpty()) assertEquals(action, result.pendingAction) } @Test - fun `v2 opens a token deeplink as a pending action on the wallet home`() { - // The expanded card is applied on top of the wallet, which is already the v2 launch base -- + fun `a token deeplink opens as a pending action on the wallet home`() { + // The expanded card is applied on top of the wallet, which is already the launch base -- // no pushed screen, so nothing lands in deeplinkRoutes. See DeeplinkAction.OpenToken. val action = DeeplinkAction.OpenToken( mint = Mint(MINT), routes = listOf(AppRoute.Sheets.Wallet, AppRoute.Token.Info(Mint(MINT))), ) - val result = build( - state = AuthState.Ready, - action = action, - deepLink = dummyLink, - isNewUi = true, - )!! + val result = buildReady(action) assertEquals(listOf(AppRoute.Sheets.Wallet), result.baseRoutes) assertTrue(result.deeplinkRoutes.isEmpty()) assertEquals(action, result.pendingAction) } @Test - fun `v1 opens a token deeplink through the carried route form`() { - // v1 has no card expansion, so the same action is taken as routes: the wallet sheet with - // token info inside it. - val routes = listOf(AppRoute.Sheets.Wallet, AppRoute.Token.Info(Mint(MINT))) - val result = build( - state = AuthState.Ready, - action = DeeplinkAction.OpenToken(mint = Mint(MINT), routes = routes), - deepLink = dummyLink, - isNewUi = false, - )!! - assertEquals(listOf(AppRoute.Main.Scanner), result.baseRoutes) - assertEquals(routes, result.deeplinkRoutes) - assertNull(result.pendingAction) - } - - @Test - fun `logged in with None action navigates to Scanner without deeplink routes`() { - val result = build( - state = AuthState.Ready, - action = DeeplinkAction.None, - deepLink = dummyLink, - )!! - assertEquals(listOf(AppRoute.Main.Scanner), result.baseRoutes) + fun `logged in with None action opens the Wallet tab without deeplink routes`() { + val result = buildReady(DeeplinkAction.None) + assertEquals(listOf(AppRoute.Sheets.Wallet), result.baseRoutes) assertTrue(result.deeplinkRoutes.isEmpty()) } @@ -213,26 +180,13 @@ class BuildNavGraphForLaunchTest { assertNull(build(AuthState.Authenticating)) } - // -- Ready (v2 / NewUi) -- + // -- Deeplink back-stack resolution -- private val mint = Mint("So11111111111111111111111111111111111111112") - private fun buildV2( - action: DeeplinkAction = DeeplinkAction.None, - deepLink: DeepLink? = dummyLink, - ) = build(AuthState.Ready, action, deepLink, isNewUi = true)!! - @Test - fun `v2 logged in without deeplink opens on the Wallet tab`() { - val result = build(AuthState.Ready, isNewUi = true)!! - assertEquals(listOf(AppRoute.Sheets.Wallet), result.baseRoutes) - assertTrue(result.deeplinkRoutes.isEmpty()) - assertEquals(listOf(AppRoute.Sheets.Wallet), result.resolvedBackStack()) - } - - @Test - fun `v2 token deeplink pushes token info onto the Wallet tab without a sheet`() { - val result = buildV2( + fun `token deeplink pushes token info onto the Wallet tab without a sheet`() { + val result = buildReady( DeeplinkAction.Navigate( listOf(AppRoute.Sheets.Wallet, AppRoute.Token.Info(mint, fromDeeplink = true)) ) @@ -242,12 +196,12 @@ class BuildNavGraphForLaunchTest { assertEquals(2, stack.size) assertEquals(AppRoute.Sheets.Wallet, stack[0]) assertIs(stack[1]) - assertTrue(stack.none { it is AppRoute.Main.Sheet }, "v2 must not wrap a tab home in a sheet") + assertTrue(stack.none { it is AppRoute.Main.Sheet }, "a tab home must never be wrapped in a sheet") } @Test - fun `v2 tip chat deeplink switches to the Chats tab instead of a sheet over Wallet`() { - val result = buildV2( + fun `tip chat deeplink switches to the Chats tab instead of a sheet over Wallet`() { + val result = buildReady( DeeplinkAction.Navigate( listOf( AppRoute.Sheets.Tips(), @@ -266,8 +220,8 @@ class BuildNavGraphForLaunchTest { } @Test - fun `v2 email verification deeplink lands on the You tab without a sheet`() { - val result = buildV2( + fun `email verification deeplink lands on the You tab without a sheet`() { + val result = buildReady( DeeplinkAction.Navigate( listOf( AppRoute.Sheets.Menu, @@ -289,26 +243,9 @@ class BuildNavGraphForLaunchTest { } @Test - fun `v1 token deeplink still opens the wallet sheet`() { - val result = build( - state = AuthState.Ready, - action = DeeplinkAction.Navigate( - listOf(AppRoute.Sheets.Wallet, AppRoute.Token.Info(mint, fromDeeplink = true)) - ), - deepLink = dummyLink, - isNewUi = false, - )!! - - val stack = result.resolvedBackStack() - assertEquals(2, stack.size) - assertEquals(AppRoute.Main.Scanner, stack[0]) - assertIs(stack[1]) - } - - @Test - fun `v2 pending actions still launch on the Wallet tab`() { + fun `pending actions still launch on the Wallet tab`() { val action = DeeplinkAction.OpenCashLink("testEntropy") - val result = buildV2(action) + val result = buildReady(action) assertEquals(listOf(AppRoute.Sheets.Wallet), result.baseRoutes) assertEquals(action, result.pendingAction) } diff --git a/apps/flipcash/benchmark/src/main/kotlin/com/flipcash/benchmark/BaselineProfileGenerator.kt b/apps/flipcash/benchmark/src/main/kotlin/com/flipcash/benchmark/BaselineProfileGenerator.kt index b7e1d4e205..dd34ab4405 100644 --- a/apps/flipcash/benchmark/src/main/kotlin/com/flipcash/benchmark/BaselineProfileGenerator.kt +++ b/apps/flipcash/benchmark/src/main/kotlin/com/flipcash/benchmark/BaselineProfileGenerator.kt @@ -97,7 +97,7 @@ class BaselineProfileGenerator { private fun MacrobenchmarkScope.authenticatedJourneys() { scannerJourney() discoveryJourney() - sendChatJourney() + chatJourney() walletJourney() giveJourney() menuJourney() @@ -109,37 +109,16 @@ class BaselineProfileGenerator { device.waitForIdle() } - private fun MacrobenchmarkScope.sendChatJourney() { - // Open the Send tab -> contact list - device.wait(Until.findObject(By.text("Send")), TIMEOUT)?.click() - // Dismiss the "N Contacts Already On Flipcash" info dialog if it appears - device.waitForIdle() - device.findObject(By.text("OK"))?.click() - device.wait(Until.findObject(By.res("send_contact_list")), LOGIN_TIMEOUT) - device.waitForIdle() - - // Pull down to reveal the search bar, type random chars (exercises the empty - // search state), then clear it. - device.findObject(By.res("send_contact_list"))?.let { list -> - val b = list.visibleBounds - device.swipe(b.centerX(), b.top + 40, b.centerX(), b.centerY() + 200, 20) - } - device.wait(Until.findObject(By.res("send_search_field")), TIMEOUT)?.click() - device.waitForIdle() - device.executeShellCommand("input text zzqxwv") - device.waitForIdle() - device.findObject(By.res("send_search_clear"))?.click() - device.waitForIdle() - device.pressBack() // dismiss the keyboard + private fun MacrobenchmarkScope.chatJourney() { + // The chats tab lists conversations; open the first one and send a text message. A + // message is fund-free (money-safety: never tap Send Cash / confirm a spend). The + // conversation is not hardcoded — send_contact_row resolves to the first row. + openTab("nav_chats", "tips_screen") - // Open the FIRST contact's chat and send a text message. A message is fund-free - // (money-safety: never tap Send Cash / confirm a spend). Contact is not - // hardcoded — send_contact_row resolves to the first row. device.wait(Until.findObject(By.res("send_contact_row")), TIMEOUT)?.click() device.wait(Until.findObject(By.res("chat_screen")), LOGIN_TIMEOUT) device.waitForIdle() - device.findObject(By.res("chat_send_message_button"))?.click() - device.wait(Until.findObject(By.res("chat_message_input")), TIMEOUT) + device.wait(Until.findObject(By.res("chat_message_input")), TIMEOUT)?.click() device.waitForIdle() device.executeShellCommand("input text BaselineProfileTest") device.waitForIdle() @@ -150,20 +129,19 @@ class BaselineProfileGenerator { flingScroll("chat_message_list", Direction.DOWN, 2) flingScroll("chat_message_list", Direction.UP, 2) - // Back to the contact list, fling it for coverage, then back to the scanner. + // Back to the conversation list, fling it for coverage, then back to the scanner. device.pressBack() - device.wait(Until.findObject(By.res("send_contact_list")), TIMEOUT) - device.waitForIdle() - flingScroll("send_contact_list", Direction.UP, 2) - flingScroll("send_contact_list", Direction.DOWN, 1) - device.pressBack() - device.wait(Until.findObject(By.res("scanner_view")), TIMEOUT) + device.wait(Until.findObject(By.res("tips_screen")), TIMEOUT) device.waitForIdle() + flingScroll("chat_list", Direction.UP, 2) + flingScroll("chat_list", Direction.DOWN, 1) + returnToScanner() } private fun MacrobenchmarkScope.discoveryJourney() { - // Open the Discover tab from the scanner bottom nav - device.wait(Until.findObject(By.text("Discover")), TIMEOUT)?.click() + // Discovery is a wallet action tile now, not a tab of its own. + openTab("nav_wallet", "wallet_screen") + device.wait(Until.findObject(By.text("Discover Currencies")), TIMEOUT)?.click() device.wait(Until.findObject(By.res("discovery_leaderboard")), LOGIN_TIMEOUT) device.waitForIdle() @@ -194,16 +172,13 @@ class BaselineProfileGenerator { // Back to the leaderboard, then fling-scroll it so TokenLeaderboard / // TokenMetricsRow / RankBadge composition + layout get compiled. - device.wait(Until.findObject(By.res("action_back")), TIMEOUT)?.click() + device.pressBack() device.wait(Until.findObject(By.res("discovery_leaderboard")), TIMEOUT) device.waitForIdle() flingScroll("discovery_leaderboard", Direction.UP, 3) // scroll down through the list flingScroll("discovery_leaderboard", Direction.DOWN, 2) // and back up - // Close discovery to the scanner - device.wait(Until.findObject(By.res("action_close")), TIMEOUT)?.click() - device.wait(Until.findObject(By.res("scanner_view")), TIMEOUT) - device.waitForIdle() + returnToScanner() } /** @@ -244,27 +219,29 @@ class BaselineProfileGenerator { } private fun MacrobenchmarkScope.walletJourney() { - // Open wallet sheet - device.wait(Until.findObject(By.text("Wallet")), TIMEOUT)?.click() - device.wait(Until.findObject(By.res("wallet_screen")), TIMEOUT) - device.waitForIdle() + openTab("nav_wallet", "wallet_screen") - // Open token info + // Tapping a card EXPANDS it in place (card-expand) rather than pushing a screen; the + // overlay carries the same token_info_screen anchor as the pushed currency-info screen. device.wait(Until.findObject(By.text("Float")), TIMEOUT)?.click() device.wait(Until.findObject(By.res("token_info_screen")), TIMEOUT) device.waitForIdle() - // Back to wallet + // Collapse the card back into the deck. device.pressBack() device.waitForIdle() - // Close sheet — swipe down to return to scanner - dismissSheet() + returnToScanner() } private fun MacrobenchmarkScope.giveJourney() { - // Open the Cash tab (the give/cash screen with the amount keypad) - device.wait(Until.findObject(By.text("Cash")), TIMEOUT)?.click() + // There is no cash tab: giving is an action on a currency you hold, reached from that + // currency's own info surface. + openTab("nav_wallet", "wallet_screen") + device.wait(Until.findObject(By.text("Float")), TIMEOUT)?.click() + device.wait(Until.findObject(By.res("token_info_screen")), TIMEOUT) + device.waitForIdle() + device.findObject(By.text("Give"))?.click() device.wait(Until.findObject(By.res("keypad_dot")), TIMEOUT) device.waitForIdle() @@ -279,31 +256,49 @@ class BaselineProfileGenerator { device.findObject(By.text("Next"))?.click() device.wait(Until.findObject(By.res("cash_bill")), LOGIN_TIMEOUT) device.waitForIdle() + // Cancelling puts the bill back and pops to the currency it was given from. device.findObject(By.text("Cancel"))?.click() - device.wait(Until.findObject(By.res("scanner_view")), TIMEOUT) + device.wait(Until.findObject(By.res("token_info_screen")), TIMEOUT) device.waitForIdle() + + returnToScanner() } private fun MacrobenchmarkScope.menuJourney() { - // Open menu - device.wait(Until.findObject(By.res("menu_button")), TIMEOUT)?.click() - device.wait(Until.findObject(By.res("menu_screen")), TIMEOUT) - device.waitForIdle() + // The "You" tab is the menu surface: tip card on top, settings list below. + openTab("nav_tipcard", "menu_screen") + flingScroll("menu_screen", Direction.UP, 2) + flingScroll("menu_screen", Direction.DOWN, 1) + returnToScanner() + } - // Close sheet - dismissSheet() + /** Switch to a tab by its nav-bar anchor and wait for that tab's home to render. */ + private fun MacrobenchmarkScope.openTab(navResId: String, homeResId: String) { + device.wait(Until.findObject(By.res(navResId)), TIMEOUT)?.click() + device.wait(Until.findObject(By.res(homeResId)), LOGIN_TIMEOUT) + device.waitForIdle() } - private fun MacrobenchmarkScope.dismissSheet() { - // Swipe from mid-screen downward to dismiss bottom sheet. - // Avoid starting near the top to prevent pulling the notification panel. - device.swipe( - device.displayWidth / 2, - device.displayHeight / 3, - device.displayWidth / 2, - device.displayHeight * 3 / 4, - 10, - ) + /** + * Return to the scanner tab. + * + * Tabs are REPLACED on a single root back stack, so Back never unwinds between them — the + * only way home is the tab itself. Pop anything pushed over the current tab first (bounded, + * so a stuck screen can't spin), then switch. An EXPANDED wallet card is not a nav entry and + * leaves the nav bar in the hierarchy behind it (merely faded), so it's probed separately. + */ + private fun MacrobenchmarkScope.returnToScanner() { + var guard = 0 + while ( + guard++ < 4 && + (!device.hasObject(By.res("nav_scanner")) || device.hasObject(By.res("token_info_screen"))) + ) { + device.pressBack() + device.waitForIdle() + } + if (!device.hasObject(By.res("scanner_view"))) { + device.wait(Until.findObject(By.res("nav_scanner")), TIMEOUT)?.click() + } device.wait(Until.findObject(By.res("scanner_view")), TIMEOUT) device.waitForIdle() } diff --git a/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/LongPressDraggable.kt b/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/LongPressDraggable.kt deleted file mode 100644 index 41c0d4e68e..0000000000 --- a/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/LongPressDraggable.kt +++ /dev/null @@ -1,128 +0,0 @@ -package com.flipcash.app.core.ui - -import androidx.compose.animation.core.Spring -import androidx.compose.animation.core.animateIntAsState -import androidx.compose.animation.core.spring -import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress -import androidx.compose.foundation.layout.offset -import androidx.compose.runtime.Composable -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.layout.onSizeChanged -import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.zIndex -import kotlin.math.roundToInt - -class LongPressDraggableState internal constructor( - internal val itemCount: Int, -) { - internal val draggingIndex = mutableIntStateOf(-1) - internal val dragOffsetX = mutableFloatStateOf(0f) - internal val itemWidthPx = mutableIntStateOf(0) - internal var onReorder: (from: Int, to: Int) -> Unit = { _, _ -> } -} - -/** - * @param key When this key changes the drag state resets. Pass the current order - * so that after a reorder propagates, the state clears atomically - * with the new layout positions. - */ -@Composable -fun rememberLongPressDraggableState( - itemCount: Int, - key: Any? = null, - onReorder: (from: Int, to: Int) -> Unit, -): LongPressDraggableState { - val state = remember(itemCount, key) { LongPressDraggableState(itemCount) } - state.onReorder = onReorder - return state -} - -@Composable -fun Modifier.longPressDraggable( - state: LongPressDraggableState, - index: Int, -): Modifier { - val displacement by remember(state, index) { - derivedStateOf { - val currentDragging = state.draggingIndex.intValue - if (currentDragging == -1 || currentDragging == index) { - 0 - } else { - val w = state.itemWidthPx.intValue - if (w <= 0) 0 - else { - val draggedVisualSlot = (currentDragging + - (state.dragOffsetX.floatValue / w).roundToInt()) - .coerceIn(0, state.itemCount - 1) - when { - currentDragging < draggedVisualSlot && - index in (currentDragging + 1)..draggedVisualSlot -> -w - currentDragging > draggedVisualSlot && - index in draggedVisualSlot until currentDragging -> w - else -> 0 - } - } - } - } - } - val animatedDisplacement by animateIntAsState( - targetValue = displacement, - animationSpec = spring(stiffness = Spring.StiffnessMediumLow), - ) - - return this - .zIndex(if (state.draggingIndex.intValue == index) 1f else 0f) - .offset { - val currentDragging = state.draggingIndex.intValue - val dx = when { - // Dragged item follows finger directly - currentDragging == index -> state.dragOffsetX.floatValue.roundToInt() - // Non-dragged items animate during an active drag - currentDragging != -1 -> animatedDisplacement - // No drag active — snap to natural position - else -> 0 - } - IntOffset(dx, 0) - } - .onSizeChanged { state.itemWidthPx.intValue = it.width } - .pointerInput(state) { - detectDragGesturesAfterLongPress( - onDragStart = { - state.draggingIndex.intValue = index - state.dragOffsetX.floatValue = 0f - }, - onDrag = { change, dragAmount -> - change.consume() - state.dragOffsetX.floatValue += dragAmount.x - }, - onDragEnd = { - val w = state.itemWidthPx.intValue - if (w > 0) { - val from = state.draggingIndex.intValue - val to = (from + (state.dragOffsetX.floatValue / w).roundToInt()) - .coerceIn(0, state.itemCount - 1) - if (from != to) { - // Snap offset to exact target so item stays visually - // in place until the reorder propagates and the state - // resets via key change. - state.dragOffsetX.floatValue = (to - from).toFloat() * w - state.onReorder(from, to) - return@detectDragGesturesAfterLongPress - } - } - state.draggingIndex.intValue = -1 - state.dragOffsetX.floatValue = 0f - }, - onDragCancel = { - state.draggingIndex.intValue = -1 - state.dragOffsetX.floatValue = 0f - }, - ) - } -} diff --git a/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/NavigationBar.kt b/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/NavigationBar.kt index 2afb423a14..2379e721a6 100644 --- a/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/NavigationBar.kt +++ b/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/NavigationBar.kt @@ -1,27 +1,17 @@ package com.flipcash.app.core.ui import androidx.annotation.DrawableRes -import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.Spring import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.spring -import androidx.compose.animation.core.tween -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.scaleIn -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.Image import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.IntrinsicSize -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -29,111 +19,64 @@ import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.border import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.State import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.produceState import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.drawWithContent -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.BlendMode import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.graphics.CompositingStrategy import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.lerp -import androidx.compose.ui.graphics.painter.Painter -import androidx.compose.ui.layout.Layout -import androidx.compose.ui.layout.layoutId -import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewWrapper -import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.compose.ui.util.fastForEach -import androidx.compose.ui.zIndex import com.flipcash.app.core.navigation.NavBarButton import dev.chrisbanes.haze.HazeInput import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.blur.HazeBlurStyle import dev.chrisbanes.haze.blur.HazeColorEffect import dev.chrisbanes.haze.blur.hazeBlur -import com.flipcash.app.core.navigation.NavBarConfig import com.flipcash.app.theme.FlipcashThemeWrapper import com.flipcash.core.R import com.getcode.theme.CodeTheme -import com.getcode.theme.xxl import com.getcode.ui.components.Badge -import com.getcode.ui.components.Pill -import com.getcode.ui.core.unboundedClickable -import com.getcode.ui.utils.heightOrZero -import com.getcode.ui.utils.widthOrZero data class NavigationBarState( - val isNewUi: Boolean, - val config: NavBarConfig, // Route-driven: the caller derives this from the current backstack tab so the highlighted tab // is correct on launch and persists while a sheet/modal is open (not tap-managed). val selectedTab: NavBarButton = NavBarButton.Wallet, val tipUnreadCount: Int = 0, - val showToast: Boolean = false, - val toastText: String? = null, - val isPaused: Boolean = false, ) { - /** - * Unread count to badge [button] with, or 0 for none. Tips and Chats are the same tip-DM - * inbox under the two UIs — v1 surfaces it on the Tips button, v2 on the Chat tab. - */ + /** Unread count to badge [button] with, or 0 for none. Only the tip-DM inbox badges. */ fun badgeCount(button: NavBarButton): Int = when (button) { - NavBarButton.Tips, NavBarButton.Chats -> tipUnreadCount + NavBarButton.Chats -> tipUnreadCount else -> 0 } } @Composable fun rememberNavigationBarState( - isNewUi: Boolean, - config: NavBarConfig, selectedTab: NavBarButton = NavBarButton.Wallet, tipUnreadCount: Int = 0, - showToast: Boolean = false, - toastText: String? = null, - isPaused: Boolean = false, ): NavigationBarState { return produceState( initialValue = NavigationBarState( - isNewUi = isNewUi, - config = config, selectedTab = selectedTab, tipUnreadCount = tipUnreadCount, - showToast = showToast, - toastText = toastText, - isPaused = isPaused, ), - isNewUi, config, selectedTab, tipUnreadCount, showToast, toastText, isPaused, + selectedTab, tipUnreadCount, ) { value = NavigationBarState( - isNewUi = isNewUi, - config = config, selectedTab = selectedTab, tipUnreadCount = tipUnreadCount, - showToast = showToast, - toastText = toastText, - isPaused = isPaused, ) }.value } @@ -143,128 +86,16 @@ fun NavigationBar( modifier: Modifier = Modifier, state: NavigationBarState, onButtonClick: (NavBarButton) -> Unit = {}, - onOrderChanged: ((List) -> Unit)? = null, hazeState: HazeState? = null, ) { - val reorderState = onOrderChanged?.let { - rememberLongPressDraggableState( - itemCount = state.config.order.size, - key = state.config.order, - onReorder = { from, to -> - val newOrder = state.config.order.toMutableList() - val item = newOrder.removeAt(from) - newOrder.add(to, item) - onOrderChanged(newOrder) - }, - ) - } - - if (state.isNewUi) { - NavigationBarV2(state, onButtonClick, modifier, reorderState, onOrderChanged, hazeState) - return - } - - Row( - modifier = Modifier - .fillMaxWidth() - .then(modifier), - verticalAlignment = Alignment.Bottom, - horizontalArrangement = Arrangement.SpaceAround, - ) { - val imageSize by animateDpAsState(if (state.config.order.size < 5) CodeTheme.dimens.staticGrid.x10 else CodeTheme.dimens.staticGrid.x7) - state.config.order.forEachIndexed { index, button -> - val buttonModifier = if (reorderState != null) { - Modifier - .weight(1f) - .longPressDraggable(reorderState, index) - } else { - Modifier.weight(1f) - } - - when (button) { - NavBarButton.Give -> BottomBarAction( - modifier = buttonModifier, - label = stringResource(state.config.giveButtonLabel.labelRes), - painter = painterResource(R.drawable.ic_cash_bill), - badgeCount = 0, - imageSize = imageSize, - onClick = { onButtonClick(NavBarButton.Give) } - ) - - NavBarButton.Wallet -> BottomBarAction( - modifier = buttonModifier, - label = stringResource(R.string.action_wallet), - painter = painterResource(R.drawable.ic_flipcash_balance), - onClick = { onButtonClick(NavBarButton.Wallet) }, - imageSize = imageSize, - toast = { - AnimatedVisibility( - visible = state.showToast && state.toastText != null, - enter = slideInVertically( - animationSpec = tween(600), - initialOffsetY = { it }) + - fadeIn(animationSpec = tween(500, 100)), - exit = if (!state.isPaused) - slideOutVertically( - animationSpec = tween(600), - targetOffsetY = { it }) + - fadeOut(animationSpec = tween(500, 100)) - else fadeOut(animationSpec = tween(0)), - ) { - Pill( - text = state.toastText.orEmpty(), - textStyle = CodeTheme.typography.textSmall.copy( - fontWeight = FontWeight.Bold - ), - shape = CodeTheme.shapes.xxl, - ) - } - } - ) - - NavBarButton.Discover -> BottomBarAction( - modifier = buttonModifier, - label = stringResource(R.string.action_discover), - painter = painterResource(R.drawable.ic_coins), - badgeCount = 0, - imageSize = imageSize, - onClick = { onButtonClick(NavBarButton.Discover) } - ) - - NavBarButton.Tips -> BottomBarAction( - modifier = buttonModifier, - label = stringResource(R.string.action_tips), - badgeCount = state.tipUnreadCount, - painter = painterResource(R.drawable.ic_tipping_hand), - imageSize = imageSize, - onClick = { onButtonClick(NavBarButton.Tips) } - ) - - NavBarButton.Chats -> Unit - NavBarButton.TipCard -> Unit - NavBarButton.Scanner -> Unit - } - } - } -} - -@Composable -private fun NavigationBarV2( - state: NavigationBarState, - onButtonClick: (NavBarButton) -> Unit, - modifier: Modifier = Modifier, - reorderState: LongPressDraggableState? = null, - onOrderChanged: ((List) -> Unit)? = null, - hazeState: HazeState? = null, -) { - val order = state.config.order + val order = NavBarButton.tabs if (order.isEmpty()) return val iconSize = CodeTheme.dimens.staticGrid.x6 val itemHeight = iconSize + CodeTheme.dimens.staticGrid.x2 * 2 val selectedIndex = order.indexOf(state.selectedTab) .takeIf { it >= 0 && it <= order.lastIndex } - ?: state.config.order.indexOf(NavBarButton.Wallet) + ?: order.indexOf(NavBarButton.Wallet) // Frost the pill over whatever content scrolls beneath it, iOS "liquid glass" style: a wide blur // plus a strong tint toward the BACKGROUND colour (not black) at high alpha. Over empty/dark @@ -332,6 +163,7 @@ private fun NavigationBarV2( modifier = Modifier .weight(1f) .height(itemHeight) + .testTag(button.testTag) // Deliberately unclipped: the unread badge overhangs the icon's top-right // corner and a clip would shave it. Safe because the click indication is // null, so there is no ripple that needs bounding. @@ -367,6 +199,19 @@ private fun NavigationBarV2( } } +/** + * Stable UI-test anchor per tab. The bar is icon-only -- no labels, and the glyphs carry no content + * description -- so without these ids the tabs are unaddressable from Maestro/UiAutomator. These are + * what `maestro/subflows/navigate_to_*.yaml` tap; keep them in sync with those flows. + */ +internal val NavBarButton.testTag: String + get() = when (this) { + NavBarButton.Scanner -> "nav_scanner" + NavBarButton.Wallet -> "nav_wallet" + NavBarButton.Chats -> "nav_chats" + NavBarButton.TipCard -> "nav_tipcard" + } + @get:DrawableRes private val NavBarButton.icon: Int get() = when (this) { @@ -374,177 +219,14 @@ private val NavBarButton.icon: Int NavBarButton.Wallet -> R.drawable.ic_nav_wallet NavBarButton.Chats -> R.drawable.ic_nav_chat NavBarButton.TipCard -> R.drawable.ic_nav_tipcard - NavBarButton.Give -> R.drawable.ic_cash_bill - NavBarButton.Discover -> R.drawable.ic_coins - NavBarButton.Tips -> R.drawable.ic_tipping_hand } -@Composable -private fun BottomBarAction( - painter: Painter, - label: String, - modifier: Modifier = Modifier, - contentPadding: PaddingValues = PaddingValues( - vertical = CodeTheme.dimens.grid.x2 - ), - imageSize: Dp = CodeTheme.dimens.staticGrid.x10, - toast: @Composable () -> Unit = { }, - badgeCount: Int = 0, - onClick: (() -> Unit)?, -) { - Column( - modifier = modifier - .then(if (badgeCount > 0) Modifier.zIndex(1f) else Modifier) - .width(IntrinsicSize.Max), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - toast() - BottomBarAction( - label = label, - contentPadding = contentPadding, - painter = painter, - imageSize = imageSize, - badge = { - Badge( - count = badgeCount, - color = CodeTheme.colors.indicator, - scale = 1.275f, - enterTransition = scaleIn( - animationSpec = tween( - durationMillis = 300, - delayMillis = 1000 - ) - ) + fadeIn() - ) - }, - onClick = onClick - ) - } -} - -@Composable -private fun BottomBarAction( - modifier: Modifier = Modifier, - label: String, - contentPadding: PaddingValues = PaddingValues( - vertical = CodeTheme.dimens.grid.x2 - ), - painter: Painter, - iconColor: Color = Color.White, - textColor: Color = Color.White, - imageSize: Dp = CodeTheme.dimens.staticGrid.x10, - badge: @Composable () -> Unit = { }, - onClick: (() -> Unit)?, -) { - val maskPadding = 4.dp - var badgeSize by remember { mutableStateOf(IntSize.Zero) } - - Layout( - modifier = modifier, - content = { - Column( - modifier = Modifier - .unboundedClickable( - enabled = onClick != null, - rippleRadius = imageSize - ) { onClick?.invoke() } - .layoutId("action"), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Image( - modifier = Modifier - .graphicsLayer { compositingStrategy = CompositingStrategy.Offscreen } - .drawWithContent { - drawContent() - val bs = badgeSize - if (bs.width > 0 && bs.height > 0) { - val mp = maskPadding.toPx() - val cpTop = contentPadding.calculateTopPadding().toPx() - drawCircle( - color = Color.Black, - radius = bs.height / 2f + mp, - center = Offset(size.width, cpTop), - blendMode = BlendMode.DstOut, - ) - } - } - .padding(contentPadding) - .size(imageSize), - painter = painter, - colorFilter = ColorFilter.tint(iconColor), - contentDescription = null, - ) - Text( - text = label, - style = CodeTheme.typography.textSmall, - color = textColor - ) - } - - Box( - modifier = Modifier - .layoutId("badge") - .onSizeChanged { badgeSize = it } - ) { - badge() - } - } - ) { measurables, incomingConstraints -> - val constraints = incomingConstraints.copy(minWidth = 0, minHeight = 0) - val actionPlaceable = - measurables.find { it.layoutId == "action" }?.measure(constraints) - val badgePlaceable = - measurables.find { it.layoutId == "badge" }?.measure(constraints) - - val badgeWidth = widthOrZero(badgePlaceable) - val badgeHeight = heightOrZero(badgePlaceable) - - val actionWidth = widthOrZero(actionPlaceable) - val actionHeight = heightOrZero(actionPlaceable) - - // Position badge so its left circular end is centered on the icon's top-right corner - val imageSizePx = imageSize.roundToPx() - val iconTop = contentPadding.calculateTopPadding().roundToPx() - val iconRight = (actionWidth + imageSizePx) / 2 - val badgeX = iconRight - badgeHeight / 2 - val badgeY = iconTop - badgeHeight / 2 - - layout( - width = actionWidth, - height = actionHeight, - ) { - actionPlaceable?.placeRelative(0, 0) - badgePlaceable?.placeRelativeWithLayer(x = badgeX, y = badgeY) { - clip = false - } - } - } -} - - @Preview @PreviewWrapper(FlipcashThemeWrapper::class) @Composable private fun NavigationBarPreview() { NavigationBar( - state = rememberNavigationBarState( - isNewUi = false, - config = NavBarConfig.Default, - tipUnreadCount = 100 - ), - ) -} - -@Preview -@PreviewWrapper(FlipcashThemeWrapper::class) -@Composable -private fun NavigationBarV2Preview() { - NavigationBarV2( - state = rememberNavigationBarState( - isNewUi = true, - config = NavBarConfig(NavBarButton.v2Order), - tipUnreadCount = 100 - ), + state = rememberNavigationBarState(tipUnreadCount = 100), onButtonClick = { } ) } diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt index 629dbe7667..ff6c0297c5 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt @@ -269,23 +269,19 @@ sealed interface AppRoute : NavKey, Parcelable { /** * The withdraw flow. * - * [preselectedMint] picks the entry step: `null` opens the currency picker (the v2 - * "Withdraw Money" tile and the v2 settings entry), Dollars/USDF detours through the + * [preselectedMint] picks the entry step: `null` opens the currency picker (the + * "Withdraw Money" tile and the settings entry), Dollars/USDF detours through the * "Withdraw as USDC" intro, and any other currency lands straight on the amount screen. - * - * [showOtherOptions] is the intro's legacy "Withdraw Other Flipcash Currencies" escape - * hatch — v1 only, since v2 reaches every currency through the picker. */ @Serializable data class Withdrawal( - val showOtherOptions: Boolean = true, val preselectedMint: Mint? = Mint.usdf, ) : Transfers, FlowRouteWithResult { override val initialStack: List get() = when (preselectedMint) { null -> listOf(WithdrawalStep.SelectToken) // The flow models USDF→USDC as a USDC withdrawal, so both mints mean the reserve. - Mint.usdf, Mint.usdc -> listOf(WithdrawalStep.UsdcInformational(showOtherOptions)) + Mint.usdf, Mint.usdc -> listOf(WithdrawalStep.UsdcInformational) else -> listOf(WithdrawalStep.Amount(preselectedMint)) } } @@ -311,8 +307,6 @@ sealed interface AppRoute : NavKey, Parcelable { data object UserProfile : Menu @Serializable data class Lab(val onboarding: Boolean = false) : Menu - @Serializable - data object NavBarSettings : Menu, com.getcode.navigation.Sheet, com.getcode.navigation.WrapContentSheet } @Serializable diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/extensions/CodeNavigator.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/extensions/CodeNavigator.kt index 48410bbe51..dd38e8b767 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/extensions/CodeNavigator.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/extensions/CodeNavigator.kt @@ -35,12 +35,12 @@ fun CodeNavigator.openAsSheet(route: AppRoute, innerRoutes: List = emp } /** - * True when [routes] leads with a route that is a v2 tab home under the NewUi flag. + * True when [routes] leads with a route that is a tab home. * Such a list is applied as a *tab switch* (the leading route replaces the stack) rather than * stacked on top of whatever tab the user was on. */ -private fun leadsWithTab(routes: List, isNewUi: Boolean): Boolean = - isNewUi && (routes.firstOrNull() as? AppRoute)?.asNavBarTab() != null +private fun leadsWithTab(routes: List): Boolean = + (routes.firstOrNull() as? AppRoute)?.asNavBarTab() != null /** * Navigate to multiple routes, wrapping [AppRoute.Sheets] in [AppRoute.Main.Sheet]. @@ -50,23 +50,22 @@ private fun leadsWithTab(routes: List, isNewUi: Boolean): Boolean = * If a sheet is already open and the new routes include a sheet, the current sheet * is animated closed before the new one opens. * - * Under [isNewUi] (v2) the tab homes — `Sheets.Wallet`, `Sheets.Tips`, `Sheets.Menu` — are - * *not* sheets, so a route list leading with one switches to that tab (replacing the stack) - * and pushes the rest on top of it. See [resolveRoutes]. + * The tab homes — `Sheets.Wallet`, `Sheets.Tips`, `Sheets.Menu` — are *not* sheets, so a route + * list leading with one switches to that tab (replacing the stack) and pushes the rest on top + * of it. See [resolveRoutes]. */ fun CodeNavigator.navigateAll( routes: List, options: NavOptions = NavOptions(), - isNewUi: Boolean = false, ) { if (routes.isEmpty()) return - val resolved = resolveRoutes(routes, isNewUi) + val resolved = resolveRoutes(routes) val needsSheet = resolved.any { it is AppRoute.Main.Sheet } val hasSheet = backStack.any { it is AppRoute.Main.Sheet } - // A v2 tab home lands as a tab switch, not another entry stacked on the current tab. - val firstOptions = if (leadsWithTab(resolved, isNewUi)) { + // A tab home lands as a tab switch, not another entry stacked on the current tab. + val firstOptions = if (leadsWithTab(resolved)) { options.copy(popUpTo = NavOptions.PopUpTo.ClearAll) } else { options @@ -80,7 +79,7 @@ fun CodeNavigator.navigateAll( } // Defer when a sheet is on screen and the new stack would take it away — either because the - // target is itself a sheet (v1) or because a v2 tab switch clears the stack out from under it. + // target is itself a sheet or because a tab switch clears the stack out from under it. // pendingSheetDismiss animates the current sheet out first, then applies the navigation. if (hasSheet && (needsSheet || firstOptions.popUpTo is NavOptions.PopUpTo.ClearAll)) { pendingSheetDismiss = { @@ -101,12 +100,12 @@ fun CodeNavigator.navigateAll( * [AppRoute.Main.Sheet] with inner routes, mirroring what [navigateAll] pushes * onto the backstack. Useful for predicting the resulting stack without navigating. * - * Under [isNewUi] (v2) the tab homes — `Sheets.Wallet`, `Sheets.Tips`, `Sheets.Menu` — are - * top-level tab destinations rather than modals, so they stay flat on the root backstack (which - * keeps the hoisted nav bar visible and lets back/pop behave like a tab stack). Anything after - * the tab route is resolved independently, so a genuine sheet later in the list still wraps. + * The tab homes — `Sheets.Wallet`, `Sheets.Tips`, `Sheets.Menu` — are top-level tab destinations + * rather than modals, so they stay flat on the root backstack (which keeps the hoisted nav bar + * visible and lets back/pop behave like a tab stack). Anything after the tab route is resolved + * independently, so a genuine sheet later in the list still wraps. */ -fun resolveRoutes(routes: List, isNewUi: Boolean = false): List { +fun resolveRoutes(routes: List): List { if (routes.isEmpty()) return emptyList() val sheetIndex = routes.indexOfFirst { it is AppRoute.Sheets } @@ -114,8 +113,8 @@ fun resolveRoutes(routes: List, isNewUi: Boolean = false): List val sheetRoute = routes[sheetIndex] as AppRoute.Sheets - if (isNewUi && sheetRoute.asNavBarTab() != null) { - return routes.take(sheetIndex + 1) + resolveRoutes(routes.drop(sheetIndex + 1), isNewUi = true) + if (sheetRoute.asNavBarTab() != null) { + return routes.take(sheetIndex + 1) + resolveRoutes(routes.drop(sheetIndex + 1)) } val before = routes.take(sheetIndex) @@ -126,16 +125,15 @@ fun resolveRoutes(routes: List, isNewUi: Boolean = false): List /** * The backstack that [navigateAll] would produce for [routes] when applied on top of [base]. * - * Mirrors [navigateAll]'s tab-switch handling: a v2 route list leading with a tab home replaces + * Mirrors [navigateAll]'s tab-switch handling: a route list leading with a tab home replaces * [base] rather than stacking on it. Used to compare against the live stack and skip redundant * navigation. */ fun resolveBackStack( base: List, routes: List, - isNewUi: Boolean = false, ): List { if (routes.isEmpty()) return base - val resolved = resolveRoutes(routes, isNewUi) - return if (leadsWithTab(resolved, isNewUi)) resolved else base + resolved + val resolved = resolveRoutes(routes) + return if (leadsWithTab(resolved)) resolved else base + resolved } diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/GiveButtonLabel.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/GiveButtonLabel.kt deleted file mode 100644 index 4c3afbbab4..0000000000 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/GiveButtonLabel.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.flipcash.app.core.navigation - -import androidx.annotation.StringRes -import com.flipcash.core.R - -enum class GiveButtonLabel(@StringRes val labelRes: Int) { - Give(R.string.action_give), - Cash(R.string.action_cash), -} diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/HomeRoute.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/HomeRoute.kt index fe605365b5..c92a853776 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/HomeRoute.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/HomeRoute.kt @@ -4,11 +4,10 @@ import com.flipcash.app.core.AppRoute /** * The top-level route the app releases to once there's a session — on launch and when onboarding - * finishes. v2 opens on the Wallet tab; v1 has no tab bar and opens on the Scanner. + * finishes. * - * Under [isNewUi] the wallet is a flat tab home (not a sheet), so this route is applied with - * `replaceAll` / [com.getcode.navigation.core.NavOptions.PopUpTo.ClearAll], exactly like a tab - * switch from the nav bar. + * The wallet is a flat tab home (not a sheet), so this route is applied with `replaceAll` / + * [com.getcode.navigation.core.NavOptions.PopUpTo.ClearAll], exactly like a tab switch from the + * nav bar. */ -fun homeRoute(isNewUi: Boolean): AppRoute = - if (isNewUi) AppRoute.Sheets.Wallet else AppRoute.Main.Scanner +val homeRoute: AppRoute = AppRoute.Sheets.Wallet diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/NavBarButton.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/NavBarButton.kt index 3a8dae4d75..2c06d17054 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/NavBarButton.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/NavBarButton.kt @@ -1,17 +1,14 @@ package com.flipcash.app.core.navigation enum class NavBarButton { - Give, Wallet, - Discover, - Tips, Chats, TipCard, Scanner, ; companion object { - val defaultOrder = listOf(Discover, Give, Tips, Wallet,) - val v2Order = listOf(Scanner, Wallet, Chats, TipCard) + /** The fixed tab set, in bar order. */ + val tabs = listOf(Scanner, Wallet, Chats, TipCard) } } diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/NavBarConfig.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/NavBarConfig.kt deleted file mode 100644 index 88845b65c3..0000000000 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/NavBarConfig.kt +++ /dev/null @@ -1,54 +0,0 @@ -package com.flipcash.app.core.navigation - -/** - * Configuration for the **v1** navigation bar — the user-reorderable button order and the give - * button label, persisted through `FeatureFlag.NavBar`. - * - * The v2 ("fresh coat") bar is a fixed tab set ([NavBarButton.v2Order]) with no configuration, so it - * does not use this type at all. Keeping v1's config isolated here means `FeatureFlag.NavBar` and - * `NavBarConfig` can be deleted together when v1 is removed, without touching the v2 path. - */ -data class NavBarConfig( - val order: List = NavBarButton.defaultOrder, - val giveButtonLabel: GiveButtonLabel = GiveButtonLabel.Cash, -) { - fun serialize(): String = - "${order.joinToString(",") { it.name }}|${giveButtonLabel.name}" - - companion object { - val Default = NavBarConfig() - - fun deserialize(value: String): NavBarConfig { - if (value.isBlank()) return Default - val default = NavBarButton.defaultOrder - val parts = value.split("|") - val stored = parts.getOrNull(0) - ?.split(",") - ?.mapNotNull { runCatching { NavBarButton.valueOf(it) }.getOrNull() } - ?.ifEmpty { default } - ?: default - // Back-fill any buttons added after this order was persisted (e.g. Tips), - // inserting each at its position in defaultOrder so it lands where intended - // rather than getting appended. Without this, a persisted order that predates - // a new button would never surface it, even when its feature flag is enabled. - val order = if (stored.containsAll(default)) { - stored - } else { - default.fold(stored) { acc, button -> - if (button in acc) { - acc - } else { - val insertAt = default - .subList(0, default.indexOf(button)) - .let { preceding -> acc.indexOfLast { it in preceding } + 1 } - acc.toMutableList().apply { add(insertAt, button) } - } - } - } - val label = parts.getOrNull(1) - ?.let { runCatching { GiveButtonLabel.valueOf(it) }.getOrNull() } - ?: GiveButtonLabel.Give - return NavBarConfig(order, label) - } - } -} diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/NavBarRoutes.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/NavBarRoutes.kt index 1af421d265..ebccaa40ec 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/NavBarRoutes.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/NavBarRoutes.kt @@ -3,23 +3,21 @@ package com.flipcash.app.core.navigation import com.flipcash.app.core.AppRoute /** - * Route mapping for the hoisted v2 navigation bar (a tab bar that swaps the current top-level + * Route mapping for the hoisted navigation bar (a tab bar that swaps the current top-level * screen). Kept here because both [NavBarButton] and [AppRoute] are core types. */ -/** The top-level route a v2 tab switches to, or null if it has no destination yet. */ -fun NavBarButton.destinationRoute(): AppRoute? = when (this) { +/** The top-level route a tab switches to. */ +fun NavBarButton.destinationRoute(): AppRoute = when (this) { NavBarButton.Scanner -> AppRoute.Main.Scanner NavBarButton.Wallet -> AppRoute.Sheets.Wallet // The chats tab routes through the tipping flow seeded at the list. NavBarButton.Chats -> AppRoute.Sheets.Tips(resumed = false) // The "You" tab is the menu (settings) surface, augmented with the tip card + share. NavBarButton.TipCard -> AppRoute.Sheets.Menu - // v1-only buttons never appear in the v2 bar. - NavBarButton.Give, NavBarButton.Discover, NavBarButton.Tips -> null } -/** The v2 tab a top-level route belongs to, or null if the route isn't a tab home. */ +/** The tab a top-level route belongs to, or null if the route isn't a tab home. */ fun AppRoute.asNavBarTab(): NavBarButton? = when (this) { AppRoute.Main.Scanner -> NavBarButton.Scanner AppRoute.Sheets.Wallet -> NavBarButton.Wallet diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/withdrawal/WithdrawalStep.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/withdrawal/WithdrawalStep.kt index 99662f56dc..2500725704 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/withdrawal/WithdrawalStep.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/withdrawal/WithdrawalStep.kt @@ -14,7 +14,8 @@ import kotlinx.serialization.Serializable @Serializable sealed interface WithdrawalStep : FlowStep, Parcelable { @Parcelize - data class UsdcInformational(val showOtherOptions: Boolean): WithdrawalStep + @Serializable + data object UsdcInformational : WithdrawalStep @Parcelize @Serializable diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index fe0c8af939..7de8270354 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -601,8 +601,6 @@ 1Y this year - Select Method - Select Purchase Method USDF (%1$s) Debit Card with Pay @@ -654,16 +652,11 @@ Check back soon Features - Home Screen Developer User Flags Account User Flags - Button Order - Hold and drag to reorder - Give Button Label - Purchase Connect Your Phantom Wallet @@ -792,7 +785,6 @@ You Withdraw as USDC - Your USDF will be converted 1:1 to Solana USDC on withdrawal Your Dollars will be converted 1:1 to USDC on Solana Solana USDC @@ -825,7 +817,6 @@ Deposit %1$s Deposit Other Flipcash Currencies - Withdraw Other Flipcash Currencies Learn more Leaderboard Ranking diff --git a/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/navigation/HomeRouteTest.kt b/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/navigation/HomeRouteTest.kt index 96787b5d6c..66853ed931 100644 --- a/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/navigation/HomeRouteTest.kt +++ b/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/navigation/HomeRouteTest.kt @@ -5,25 +5,20 @@ import kotlin.test.Test import kotlin.test.assertEquals /** - * Home is where both launch and the end of onboarding release the user to. Under v2 that's the - * Wallet tab — not the camera — so a freshly onboarded user lands on their balance. + * Home is where both launch and the end of onboarding release the user to — the Wallet tab, not the + * camera — so a freshly onboarded user lands on their balance. */ class HomeRouteTest { @Test - fun `v2 home is the wallet tab`() { - assertEquals(AppRoute.Sheets.Wallet, homeRoute(isNewUi = true)) + fun `home is the wallet tab`() { + assertEquals(AppRoute.Sheets.Wallet, homeRoute) } @Test - fun `v1 home is the scanner`() { - assertEquals(AppRoute.Main.Scanner, homeRoute(isNewUi = false)) - } - - @Test - fun `v2 home is a nav bar tab home`() { + fun `home is a nav bar tab home`() { // Guards the release path: it's applied with ClearAll, so it must map to a tab or the // hoisted nav bar would render with no selection. - assertEquals(NavBarButton.Wallet, homeRoute(isNewUi = true).asNavBarTab()) + assertEquals(NavBarButton.Wallet, homeRoute.asNavBarTab()) } } diff --git a/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/navigation/NavBarConfigTest.kt b/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/navigation/NavBarConfigTest.kt deleted file mode 100644 index 0b3cb7426a..0000000000 --- a/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/navigation/NavBarConfigTest.kt +++ /dev/null @@ -1,76 +0,0 @@ -package com.flipcash.app.core.navigation - -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertTrue - -class NavBarConfigTest { - - @Test - fun `blank string yields the default config`() { - assertEquals(NavBarConfig.Default, NavBarConfig.deserialize("")) - } - - @Test - fun `round-trips a full, current order`() { - val config = NavBarConfig( - order = listOf(NavBarButton.Wallet, NavBarButton.Give, NavBarButton.Discover, NavBarButton.Tips), - giveButtonLabel = GiveButtonLabel.Cash, - ) - assertEquals(config, NavBarConfig.deserialize(config.serialize())) - } - - @Test - fun `back-fills a button added after the order was persisted, at its default position`() { - // A nav bar order persisted before Tips was added to the enum/defaultOrder. - val legacy = "Discover,Give,Wallet|Cash" - - val order = NavBarConfig.deserialize(legacy).order - - assertTrue(NavBarButton.Tips in order, "Tips should be back-filled into a legacy order") - // Inserted at its defaultOrder position (between Give and Wallet), not appended. - assertEquals( - listOf(NavBarButton.Discover, NavBarButton.Give, NavBarButton.Tips, NavBarButton.Wallet), - order, - ) - } - - @Test - fun `back-fill preserves a user's custom ordering of existing buttons`() { - val legacy = "Wallet,Give,Discover|Cash" - - val order = NavBarConfig.deserialize(legacy).order - - assertTrue(NavBarButton.Tips in order) - // Existing buttons keep the user's reversed order; only the missing one is added. - assertEquals( - listOf(NavBarButton.Wallet, NavBarButton.Give, NavBarButton.Discover), - order.filterNot { it == NavBarButton.Tips }, - ) - } - - @Test - fun `unknown button names are dropped and missing known ones back-filled`() { - val order = NavBarConfig.deserialize("Discover,Bogus,Give,Wallet|Cash").order - - assertEquals(NavBarButton.defaultOrder, order) - } - - @Test - fun `a persisted order containing the removed Send button is dropped without crashing`() { - // Users who customized their nav bar before Send was removed have "Send" persisted in - // their NavBar config. deserialize() must silently drop the now-unknown token (never - // throw on NavBarButton.valueOf) and back-fill the current default order. - val order = NavBarConfig.deserialize("Discover,Give,Send,Tips,Wallet|Cash").order - - assertTrue("Send" !in order.map { it.name }, "the removed Send token must not survive") - assertEquals(NavBarButton.defaultOrder, order) - } - - @Test - fun `empty order falls back to the default order`() { - val order = NavBarConfig.deserialize("|Cash").order - - assertEquals(NavBarButton.defaultOrder, order) - } -} diff --git a/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/navigation/NavBarRoutesTest.kt b/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/navigation/NavBarRoutesTest.kt index 936f7986d5..47e8fa433b 100644 --- a/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/navigation/NavBarRoutesTest.kt +++ b/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/navigation/NavBarRoutesTest.kt @@ -3,10 +3,9 @@ package com.flipcash.app.core.navigation import com.flipcash.app.core.AppRoute import kotlin.test.Test import kotlin.test.assertEquals -import kotlin.test.assertNull /** - * The v2 "You" tab is the menu (settings) surface. Mirrors iOS `YouTabRoutingTests`: the TipCard tab + * The "You" tab is the menu (settings) surface. Mirrors iOS `YouTabRoutingTests`: the TipCard tab * routes to the menu, and the menu route belongs to the TipCard tab — the tipping flow is now home * only to the Chats tab. */ @@ -33,11 +32,4 @@ class NavBarRoutesTest { assertEquals(NavBarButton.Chats, AppRoute.Sheets.Tips(resumed = true).asNavBarTab()) assertEquals(NavBarButton.Chats, AppRoute.Sheets.Tips(resumed = false).asNavBarTab()) } - - @Test - fun `v1-only buttons have no v2 destination`() { - assertNull(NavBarButton.Give.destinationRoute()) - assertNull(NavBarButton.Discover.destinationRoute()) - assertNull(NavBarButton.Tips.destinationRoute()) - } } diff --git a/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/BalanceScreen.kt b/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/BalanceScreen.kt deleted file mode 100644 index 877df4c445..0000000000 --- a/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/BalanceScreen.kt +++ /dev/null @@ -1,69 +0,0 @@ -package com.flipcash.app.balance - -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import com.flipcash.app.balance.internal.BalanceScreen -import com.flipcash.app.balance.internal.WalletViewModel -import com.flipcash.app.core.AppRoute -import com.flipcash.app.core.tokens.TokenPurpose -import com.flipcash.app.tokens.ui.SelectTokenViewModel -import com.flipcash.core.R -import com.getcode.navigation.core.LocalCodeNavigator -import com.getcode.ui.components.AppBarDefaults -import com.getcode.ui.components.AppBarWithTitle -import kotlinx.coroutines.flow.filterIsInstance -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onEach - -@Deprecated("Replaced by WalletScreen in new UI") -@Composable -fun BalanceScreen() { - val navigator = LocalCodeNavigator.current - Column( - modifier = Modifier.fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - AppBarWithTitle( - title = stringResource(R.string.title_wallet), - titleAlignment = Alignment.CenterHorizontally, - endContent = { - AppBarDefaults.Close { navigator.hide() } - } - ) - - val viewModel = hiltViewModel() - val tokenViewModel = hiltViewModel() - BalanceScreen(viewModel, tokenViewModel) - - LaunchedEffect(tokenViewModel) { - tokenViewModel.dispatchEvent( - SelectTokenViewModel.Event.OnPurposeChanged( - TokenPurpose.Balance - ) - ) - } - - LaunchedEffect(viewModel) { - viewModel.eventFlow - .filterIsInstance() - .onEach { - navigator.push(AppRoute.Main.RegionSelection) - }.launchIn(this) - } - - LaunchedEffect(viewModel) { - viewModel.eventFlow - .filterIsInstance() - .map { it.screen } - .onEach { navigator.push(it) } - .launchIn(this) - } - } -} \ No newline at end of file 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 deleted file mode 100644 index 4c118de098..0000000000 --- a/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/BalanceScreenContent.kt +++ /dev/null @@ -1,186 +0,0 @@ -package com.flipcash.app.balance.internal - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.navigationBarsPadding -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.tooling.preview.PreviewWrapper -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.flipcash.app.balance.internal.components.BalanceHeader -import com.flipcash.app.core.AppRoute -import com.flipcash.app.core.tokens.TokenPurpose -import com.flipcash.app.tokens.ui.SelectTokenViewModel -import com.flipcash.app.core.ui.rememberTokenBalanceRowStyling -import com.flipcash.app.theme.FlipcashThemeWrapper -import com.flipcash.app.tokens.ui.TokenList -import com.flipcash.features.balance.R -import com.getcode.opencode.compose.ExchangeStub -import com.getcode.opencode.compose.LocalExchange -import com.getcode.opencode.model.financial.CurrencyCode -import com.getcode.opencode.model.financial.Rate -import com.getcode.theme.CodeTheme -import com.getcode.ui.theme.ButtonState -import com.getcode.ui.theme.CodeButton - -@Composable -internal fun BalanceScreen( - viewModel: WalletViewModel, - tokenViewModel: SelectTokenViewModel, -) { - val balanceState by viewModel.stateFlow.collectAsStateWithLifecycle() - val tokenState by tokenViewModel.stateFlow.collectAsStateWithLifecycle() - BalanceScreenContent( - tokenState = tokenState, - dispatchEvent = viewModel::dispatchEvent - ) -} - -@Composable -internal fun BalanceScreenContent( - tokenState: SelectTokenViewModel.State, - dispatchEvent: (WalletViewModel.Event) -> Unit -) { - Column { - val tokens = remember(tokenState.tokens) { tokenState.tokens } - val isEmpty = tokens.orEmpty().isEmpty() - - val headerContent: @Composable () -> Unit = { - BalanceHeader( - modifier = Modifier - .fillMaxWidth(), - balance = tokenState.totalBalance, - appreciation = tokenState.aggregateAppreciation, - ) { - dispatchEvent(WalletViewModel.Event.OpenCurrencySelection) - } - - Spacer(modifier = Modifier.padding(CodeTheme.dimens.grid.x2)) - } - - TokenList( - modifier = Modifier.weight(1f), - itemModifier = { Modifier.animateItem(fadeInSpec = null) }, - styling = rememberTokenBalanceRowStyling(), - // When empty, the header is rendered inside the empty state (pinned - // to the top) so the prompt can be centered against the full - // viewport. As a separate leading list item it would offset the - // empty state down by the header's height. - header = if (isEmpty) null else headerContent, - emptyState = { - Box(modifier = Modifier.fillParentMaxSize()) { - Column(modifier = Modifier.align(Alignment.TopCenter)) { - headerContent() - } - - Column( - modifier = Modifier - .align(Alignment.Center) - .fillMaxWidth() - .padding(horizontal = CodeTheme.dimens.inset), - verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text( - text = stringResource(R.string.title_noBalanceYet), - style = CodeTheme.typography.textLarge, - color = CodeTheme.colors.textMain, - textAlign = TextAlign.Center, - ) - - Text( - modifier = Modifier.fillMaxWidth(0.6f), - text = stringResource(R.string.description_noBalanceYetForBalance), - style = CodeTheme.typography.textSmall, - color = CodeTheme.colors.textSecondary, - textAlign = TextAlign.Center, - ) - - CodeButton( - onClick = { - dispatchEvent(WalletViewModel.Event.PresentDepositOptions) - }, - modifier = Modifier - .padding(top = CodeTheme.dimens.grid.x2) - .align(Alignment.CenterHorizontally), - contentPadding = PaddingValues(), - text = stringResource(R.string.action_addMoney), - shape = CircleShape, - ) - } - } - }, - pinFooter = true, - footer = if (tokenState.totalBalance?.nativeAmount?.hasDisplayableValue == true) { - { - CodeButton( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = CodeTheme.dimens.inset) - .padding(bottom = CodeTheme.dimens.grid.x3) - .navigationBarsPadding(), - text = stringResource(R.string.action_addMoney), - buttonState = ButtonState.Filled10, - onClick = { - dispatchEvent( - WalletViewModel.Event.PresentDepositOptions - ) - } - ) - } - } else null, - tokens = tokens, - onTokenSelected = { - dispatchEvent( - WalletViewModel.Event.OpenScreen( - AppRoute.Token.Info(it.address) - ) - ) - } - ) - } -} - -private val cadUsdRate = Rate(fx = 1.371881, currency = CurrencyCode.CAD) -private val usdCadRate = Rate(fx = 1.0 / 1.371881, currency = CurrencyCode.CAD) - -@Preview -@PreviewWrapper(FlipcashThemeWrapper::class) -@Composable -private fun Preview_BalanceScreen_Empty() { - CompositionLocalProvider( - LocalExchange provides ExchangeStub( - providedRates = mapOf( - CurrencyCode.CAD to cadUsdRate, - CurrencyCode.USD to usdCadRate - ), - context = LocalContext.current - ), - ) { - Box(modifier = Modifier.background(CodeTheme.colors.background)) { - BalanceScreenContent( - tokenState = SelectTokenViewModel.State( - purpose = TokenPurpose.Balance, - tokens = emptyList() - ), - dispatchEvent = {} - ) - } - } -} \ No newline at end of file diff --git a/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/WalletScreenContent.kt b/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/WalletScreenContent.kt index 7e573b1480..02e8366440 100644 --- a/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/WalletScreenContent.kt +++ b/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/WalletScreenContent.kt @@ -76,7 +76,7 @@ internal fun WalletScreenContent( // pieces -- a spinner inside the header while the body below it had already decided, from a // still-empty cache, that this was a brand-new account and drawn the tutorial. Nothing renders // until all three can be drawn together, and BalanceHeader's own spinner is consequently dead - // code on this screen (v1's BalanceScreen still uses it). + // code -- the header is only ever drawn once the balance has resolved. // // The two waits are independent races, and the activity preview *reads* the token cache: a // convert row titles itself "USDF -> Dad Cash" from both mints' metadata and falls back to the diff --git a/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/components/CashReservesRow.kt b/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/components/CashReservesRow.kt deleted file mode 100644 index 242d3679fb..0000000000 --- a/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/components/CashReservesRow.kt +++ /dev/null @@ -1,60 +0,0 @@ -package com.flipcash.app.balance.internal.components - -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Icon -import androidx.compose.material3.Text -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 com.flipcash.app.core.money.formatted -import com.flipcash.features.balance.R -import com.getcode.opencode.model.financial.LocalFiat -import com.getcode.theme.CodeTheme -import com.getcode.ui.components.text.AnimatedNumberText -import androidx.compose.foundation.clickable - -@Composable -internal fun CashReservesRow( - reserves: LocalFiat, - onClick: () -> Unit -) { - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { - onClick() - } - .padding( - vertical = CodeTheme.dimens.grid.x3, - horizontal = CodeTheme.dimens.inset - ), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = stringResource(R.string.title_cashReserves), - style = CodeTheme.typography.screenTitle, - color = CodeTheme.colors.textSecondary, - ) - - Icon( - modifier = Modifier - .padding(start = CodeTheme.dimens.grid.x2), - painter = painterResource(id = R.drawable.ic_chevron_right), - contentDescription = null, - tint = CodeTheme.colors.textSecondary, - ) - - Spacer(Modifier.weight(1f)) - - AnimatedNumberText( - value = reserves.formatted(), - style = CodeTheme.typography.screenTitle, - color = CodeTheme.colors.textMain, - ) - } -} \ No newline at end of file diff --git a/apps/flipcash/features/balance/src/test/kotlin/com/flipcash/app/balance/internal/BalanceScreenContentTest.kt b/apps/flipcash/features/balance/src/test/kotlin/com/flipcash/app/balance/internal/BalanceScreenContentTest.kt deleted file mode 100644 index 4034050bc3..0000000000 --- a/apps/flipcash/features/balance/src/test/kotlin/com/flipcash/app/balance/internal/BalanceScreenContentTest.kt +++ /dev/null @@ -1,76 +0,0 @@ -package com.flipcash.app.balance.internal - -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.junit4.createComposeRule -import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.performClick -import com.flipcash.app.core.tokens.TokenPurpose -import com.flipcash.app.tokens.ui.SelectTokenViewModel -import com.getcode.opencode.compose.ExchangeStub -import com.getcode.opencode.compose.LocalExchange -import com.getcode.opencode.model.financial.CurrencyCode -import com.getcode.opencode.model.financial.Rate -import com.getcode.theme.DesignSystem -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import kotlin.test.assertTrue - -/** - * Regression coverage for the AddMoneyUX flag removal on the Balance screen. The flag was - * `launched = true`, so the empty-wallet call-to-action is now unconditionally the - * "Add Money" deposit path — it must never fall back to the old "Discover Currencies" - * copy/route, and tapping it must open the deposit options. - */ -@RunWith(RobolectricTestRunner::class) -class BalanceScreenContentTest { - - @get:Rule - val composeTestRule = createComposeRule() - - private var lastEvent: WalletViewModel.Event? = null - - private fun setEmptyBalanceScreen() { - lastEvent = null - composeTestRule.setContent { - DesignSystem { - CompositionLocalProvider( - LocalExchange provides ExchangeStub( - providedRates = mapOf(CurrencyCode.USD to Rate.oneToOne), - context = LocalContext.current, - ) - ) { - BalanceScreenContent( - tokenState = SelectTokenViewModel.State( - purpose = TokenPurpose.Balance, - tokens = emptyList(), - ), - dispatchEvent = { lastEvent = it }, - ) - } - } - } - } - - @Test - fun `empty wallet shows add money cta`() { - setEmptyBalanceScreen() - composeTestRule.onNodeWithText("Add Money").assertIsDisplayed() - } - - @Test - fun `empty wallet does not show discover currencies fallback`() { - setEmptyBalanceScreen() - composeTestRule.onNodeWithText("Discover Currencies").assertDoesNotExist() - } - - @Test - fun `tapping add money opens deposit options`() { - setEmptyBalanceScreen() - composeTestRule.onNodeWithText("Add Money").performClick() - assertTrue(lastEvent is WalletViewModel.Event.PresentDepositOptions) - } -} diff --git a/apps/flipcash/features/cash/src/main/kotlin/com/flipcash/app/cash/CashScreen.kt b/apps/flipcash/features/cash/src/main/kotlin/com/flipcash/app/cash/CashScreen.kt index cfc34e2bae..3548bfc69c 100644 --- a/apps/flipcash/features/cash/src/main/kotlin/com/flipcash/app/cash/CashScreen.kt +++ b/apps/flipcash/features/cash/src/main/kotlin/com/flipcash/app/cash/CashScreen.kt @@ -16,8 +16,6 @@ import com.flipcash.app.cash.internal.GiveScreenContent import com.flipcash.app.core.AppRoute import com.flipcash.app.core.tokens.TokenPurpose import com.flipcash.app.core.ui.TokenSelectionPill -import com.flipcash.app.featureflags.FeatureFlag -import com.flipcash.app.featureflags.LocalFeatureFlags import com.flipcash.app.session.LocalSessionController import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.solana.keys.Mint @@ -35,8 +33,6 @@ fun CashScreen( ) { val navigator = LocalCodeNavigator.current val session = LocalSessionController.current!! - val features = LocalFeatureFlags.current - val isNewUi by features.observe(FeatureFlag.NewUi).collectAsStateWithLifecycle() val viewModel = hiltViewModel() val state by viewModel.stateFlow.collectAsStateWithLifecycle() @@ -46,13 +42,13 @@ fun CashScreen( .filterIsInstance() .onEach { session.showBill(it.bill) - // v2 reaches this screen as a PUSH (from currency-info), not a sheet, so hide() — + // This screen is reached as a PUSH (from currency-info), not a sheet, so hide() — // which only pops when a Sheet is on the stack — would leave it up. Pop back to the // currency-info underneath so the bill presents over it. The pop is deliberately - // untransitioned (see NewAppContent's popTransitionSpec): the bill overlay + scrim are + // untransitioned (see AppContent's popTransitionSpec): the bill overlay + scrim are // drawn per nav entry, so an animated pop slides the outgoing entry's copy away while // the incoming entry composes its own — which reads as a flash behind the bill. - if (isNewUi) navigator.pop() else navigator.hide() + navigator.pop() } .launchIn(this) } diff --git a/apps/flipcash/features/discovery/src/main/kotlin/com/flipcash/app/discovery/TokenDiscoveryScreen.kt b/apps/flipcash/features/discovery/src/main/kotlin/com/flipcash/app/discovery/TokenDiscoveryScreen.kt index 1aea1e7e3a..93128ae817 100644 --- a/apps/flipcash/features/discovery/src/main/kotlin/com/flipcash/app/discovery/TokenDiscoveryScreen.kt +++ b/apps/flipcash/features/discovery/src/main/kotlin/com/flipcash/app/discovery/TokenDiscoveryScreen.kt @@ -58,10 +58,4 @@ private fun TokenDiscoveryEventHandler(viewModel: TokenDiscoveryViewModel, navig .launchIn(this) } - LaunchedEffect(viewModel) { - viewModel.eventFlow - .filterIsInstance() - .onEach { navigator.navigate(AppRoute.Token.CurrencyCreator) } - .launchIn(this) - } } \ No newline at end of file diff --git a/apps/flipcash/features/discovery/src/main/kotlin/com/flipcash/app/discovery/internal/TokenDiscoveryViewModel.kt b/apps/flipcash/features/discovery/src/main/kotlin/com/flipcash/app/discovery/internal/TokenDiscoveryViewModel.kt index bd13e01a2e..f5f3d90b9c 100644 --- a/apps/flipcash/features/discovery/src/main/kotlin/com/flipcash/app/discovery/internal/TokenDiscoveryViewModel.kt +++ b/apps/flipcash/features/discovery/src/main/kotlin/com/flipcash/app/discovery/internal/TokenDiscoveryViewModel.kt @@ -67,7 +67,6 @@ internal class TokenDiscoveryViewModel @Inject constructor( data class LoadTokensForCategory(val category: DiscoverCategory) : Event data object Refresh : Event data class OpenTokenInfo(val mint: Mint) : Event - data object CreateCurrency: Event } init { @@ -150,7 +149,6 @@ internal class TokenDiscoveryViewModel @Inject constructor( is Event.LoadTokensForCategory -> { state -> state } is Event.OpenTokenInfo -> { state -> state } is Event.Refresh -> { state -> state } - is Event.CreateCurrency -> { state -> state } Event.LearnAboutLeaderboard -> { state -> state } } } diff --git a/apps/flipcash/features/discovery/src/main/kotlin/com/flipcash/app/discovery/internal/components/TokenLeaderboard.kt b/apps/flipcash/features/discovery/src/main/kotlin/com/flipcash/app/discovery/internal/components/TokenLeaderboard.kt index 0372d12c2d..93b60db7eb 100644 --- a/apps/flipcash/features/discovery/src/main/kotlin/com/flipcash/app/discovery/internal/components/TokenLeaderboard.kt +++ b/apps/flipcash/features/discovery/src/main/kotlin/com/flipcash/app/discovery/internal/components/TokenLeaderboard.kt @@ -20,7 +20,6 @@ import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag @@ -29,14 +28,10 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.coerceAtLeast import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flipcash.app.core.data.Loadable import com.flipcash.app.core.data.isLoaded import com.flipcash.app.discovery.internal.LeaderboardEntry import com.flipcash.app.discovery.internal.TokenDiscoveryViewModel -import com.flipcash.app.featureflags.FeatureFlag -import com.flipcash.app.featureflags.LocalFeatureFlags -import com.flipcash.app.tokens.ui.CurrencyCreatorUpsellCard import com.flipcash.features.discovery.R import com.getcode.manager.BottomBarManager import com.getcode.opencode.model.ui.DiscoverCategory @@ -48,7 +43,6 @@ import com.getcode.ui.theme.ButtonState import com.getcode.ui.theme.CodeButton import com.getcode.ui.theme.CodeScaffold import com.getcode.ui.utils.sheetResignmentBehavior -import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.rememberHazeState @@ -60,19 +54,9 @@ internal fun TokenLeaderboard( dispatch: (TokenDiscoveryViewModel.Event) -> Unit ) { val reduceBottomPadding = CodeTheme.dimens.grid.x4 - val features = LocalFeatureFlags.current - val isNewUi by features.observe(FeatureFlag.NewUi).collectAsStateWithLifecycle() - - // v2 surfaces currency creation as a Wallet action tile, so Discover drops its promo entirely; - // v1 keeps it as the first row above the leaderboard. The haze source stays wired for the - // scroll gradient's benefit. + // Currency creation is surfaced as a Wallet action tile, so Discover carries no promo of its + // own. The haze source stays wired for the scroll gradient's benefit. val hazeState = rememberHazeState() - - val currencyCreatorCard = @Composable { modifier: Modifier, haze: HazeState? -> - CurrencyCreatorUpsellCard(modifier = modifier, hazeState = haze) { - dispatch(TokenDiscoveryViewModel.Event.CreateCurrency) - } - } CodeScaffold { padding -> LazyColumn( modifier = Modifier @@ -83,7 +67,7 @@ internal fun TokenLeaderboard( state, color = CodeTheme.colors.background, isLongGradient = true, - showAtEnd = isNewUi, + showAtEnd = true, ) .addIf(tokens.isLoaded()) { Modifier.sheetResignmentBehavior(state) @@ -136,11 +120,6 @@ internal fun TokenLeaderboard( is Loadable.Loaded -> { val results = tokens.data - // currency creator upsell card - if (!isNewUi) { - item { currencyCreatorCard(Modifier.fillParentMaxWidth(), null) } - } - if (results.isEmpty()) { item { Box( @@ -213,11 +192,7 @@ internal fun TokenLeaderboard( ), rank = index + 1, token = entry.token, - rankingSystem = if (isNewUi) { - RankingSystem.MarketCap - } else { - RankingSystem.Holders - }, + rankingSystem = RankingSystem.MarketCap, ) { dispatch(TokenDiscoveryViewModel.Event.OpenTokenInfo(entry.token.address)) } diff --git a/apps/flipcash/features/home/.gitignore b/apps/flipcash/features/home/.gitignore deleted file mode 100644 index 9f2a078806..0000000000 --- a/apps/flipcash/features/home/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -build/ -.gradle/ diff --git a/apps/flipcash/features/home/build.gradle.kts b/apps/flipcash/features/home/build.gradle.kts deleted file mode 100644 index 5a74d82c72..0000000000 --- a/apps/flipcash/features/home/build.gradle.kts +++ /dev/null @@ -1,21 +0,0 @@ -plugins { - alias(libs.plugins.flipcash.android.feature) -} - -android { - namespace = "${Gradle.flipcashNamespace}.features.home" -} - -dependencies { - implementation(project(":apps:flipcash:shared:appupdates")) - implementation(project(":apps:flipcash:shared:analytics")) - implementation(project(":apps:flipcash:shared:authentication")) - implementation(project(":apps:flipcash:shared:featureflags")) - implementation(project(":apps:flipcash:shared:menu")) - implementation(project(":apps:flipcash:shared:funding")) - implementation(project(":apps:flipcash:shared:userflags")) - - implementation(project(":libs:datetime")) - implementation(project(":libs:messaging")) - implementation(project(":libs:permissions:bindings")) -} diff --git a/apps/flipcash/features/home/src/main/kotlin/com/flipcash/app/home/HomeScreen.kt b/apps/flipcash/features/home/src/main/kotlin/com/flipcash/app/home/HomeScreen.kt deleted file mode 100644 index 205f61494d..0000000000 --- a/apps/flipcash/features/home/src/main/kotlin/com/flipcash/app/home/HomeScreen.kt +++ /dev/null @@ -1,47 +0,0 @@ -package com.flipcash.app.home - -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.navigationBarsPadding -import androidx.compose.foundation.layout.padding -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import com.flipcash.app.core.navigation.NavBarButton -import com.flipcash.app.core.navigation.NavBarConfig -import com.flipcash.app.core.ui.NavigationBar -import com.flipcash.app.core.ui.rememberNavigationBarState -import com.getcode.theme.CodeTheme -import com.getcode.ui.theme.CodeScaffold - -@Composable -fun HomeScreen() { - val navbarState = rememberNavigationBarState( - isNewUi = false, - config = NavBarConfig(order = NavBarButton.v2Order), - tipUnreadCount = 0, - ) - - CodeScaffold( - bottomBar = { - NavigationBar( - modifier = Modifier.fillMaxWidth() - .padding(horizontal = CodeTheme.dimens.grid.x8) - .navigationBarsPadding() - .padding(bottom = CodeTheme.dimens.grid.x3), - state = navbarState, - onButtonClick = { button -> - when (button) { - NavBarButton.Scanner -> TODO() - NavBarButton.Wallet -> TODO() - NavBarButton.Chats -> TODO() - NavBarButton.TipCard -> TODO() - NavBarButton.Give -> Unit - NavBarButton.Discover -> Unit - NavBarButton.Tips -> Unit - } - } - ) - } - ) { padding -> - - } -} \ No newline at end of file diff --git a/apps/flipcash/features/lab/src/main/kotlin/com/flipcash/app/lab/NavBarSettingsScreen.kt b/apps/flipcash/features/lab/src/main/kotlin/com/flipcash/app/lab/NavBarSettingsScreen.kt deleted file mode 100644 index 6770a5426b..0000000000 --- a/apps/flipcash/features/lab/src/main/kotlin/com/flipcash/app/lab/NavBarSettingsScreen.kt +++ /dev/null @@ -1,30 +0,0 @@ -package com.flipcash.app.lab - -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import com.flipcash.app.lab.internal.NavBarSettingsContent -import com.getcode.navigation.scenes.LocalBottomSheetDismissDispatcher -import com.getcode.ui.components.AppBarDefaults -import com.getcode.ui.components.AppBarWithTitle - -@Composable -fun NavBarSettingsScreen() { - val dismiss = LocalBottomSheetDismissDispatcher.current - - Column( - horizontalAlignment = Alignment.CenterHorizontally, - ) { - AppBarWithTitle( - title = "", - titleAlignment = Alignment.CenterHorizontally, - endContent = { - AppBarDefaults.Close { dismiss() } - } - ) - - NavBarSettingsContent() - } -} 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 84701bb11f..f1a2b5e736 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 @@ -24,12 +24,10 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.res.painterResource 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.FeatureFlag import com.flipcash.app.featureflags.FeatureTrack import com.flipcash.app.featureflags.FlagOption import com.flipcash.app.featureflags.LocalFeatureFlags @@ -37,7 +35,6 @@ import com.flipcash.app.featureflags.message import com.flipcash.app.featureflags.title import com.flipcash.features.lab.R import com.getcode.navigation.core.LocalCodeNavigator -import com.getcode.navigation.scenes.LocalSheetNavigator import com.getcode.theme.CodeTheme import com.getcode.ui.components.ListItem import com.getcode.ui.components.SettingsSwitchRow @@ -52,11 +49,6 @@ internal fun LabsScreenContent(viewModel: LabsScreenViewModel, onboarding: Boole val allFlags by betaFlagsController.observe().collectAsStateWithLifecycle() val betaOverride by viewModel.betaOverride.collectAsStateWithLifecycle() val navigator = LocalCodeNavigator.current - // When this screen is inside a sheet, LocalCodeNavigator is the sheet's OWN navigator — and it's - // created without a parent, so `rootNavigator` would resolve back to itself and any reset would - // only clear the sheet's inner stack. The sheet scene publishes its host (the app-level - // navigator that owns the sheet entry) as LocalSheetNavigator, so prefer that. - val appRootNavigator = LocalSheetNavigator.current ?: navigator.rootNavigator val isStaff by viewModel.isStaff.collectAsStateWithLifecycle() // Keep showing all flags even after toggling off override, until leaving the screen @@ -130,21 +122,7 @@ internal fun LabsScreenContent(viewModel: LabsScreenViewModel, onboarding: Boole subtitle = feature.flag.message.takeIf { showAllFlags }, checked = feature.enabled ) { - val enabling = !feature.enabled - // Switching UI shells swaps the whole nav host out from under the current - // stack, and that stack is this settings sheet — meaningless in the other - // shell (v2 would render the menu as a floating sheet instead of its "You" - // tab). Re-home onto the target shell's landing screen first, THEN flip the - // flag: resetting after the flag would race the shell swap, and an animated - // sheet dismiss can't survive it at all (the host that drives the dismiss is - // disposed mid-animation, stranding the sheet). Resetting first means the - // new shell composes against a stack that already makes sense in it. - if (feature.flag == FeatureFlag.NewUi) { - appRootNavigator.replaceAll( - if (enabling) AppRoute.Sheets.Menu else AppRoute.Main.Scanner - ) - } - betaFlagsController.set(feature.flag, enabling) + betaFlagsController.set(feature.flag, !feature.enabled) } } @@ -179,23 +157,6 @@ internal fun LabsScreenContent(viewModel: LabsScreenViewModel, onboarding: Boole } } - if (showAllFlags) { - 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 (showAllFlags && isStaff) { item(contentType = "section_header") { SectionHeader( diff --git a/apps/flipcash/features/lab/src/main/kotlin/com/flipcash/app/lab/internal/NavBarSettingsContent.kt b/apps/flipcash/features/lab/src/main/kotlin/com/flipcash/app/lab/internal/NavBarSettingsContent.kt deleted file mode 100644 index 6630ed9835..0000000000 --- a/apps/flipcash/features/lab/src/main/kotlin/com/flipcash/app/lab/internal/NavBarSettingsContent.kt +++ /dev/null @@ -1,88 +0,0 @@ -package com.flipcash.app.lab.internal - -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.navigationBarsPadding -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.wrapContentHeight -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.draw.clip -import androidx.compose.ui.res.stringResource -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.flipcash.app.core.navigation.GiveButtonLabel -import com.flipcash.app.core.navigation.NavBarConfig -import com.flipcash.app.core.ui.NavigationBar -import com.flipcash.app.core.ui.NavigationBarState -import com.flipcash.app.featureflags.FeatureFlag -import com.flipcash.app.featureflags.LocalFeatureFlags -import com.flipcash.core.R -import com.getcode.theme.CodeTheme -import com.getcode.theme.White05 -import com.getcode.ui.components.text.SectionHeader -import com.getcode.ui.theme.CodeSegmentedControl - -@Composable -internal fun NavBarSettingsContent() { - val featureFlags = LocalFeatureFlags.current - val configString by featureFlags.getOption(FeatureFlag.NavBar) - .collectAsStateWithLifecycle() - val config = remember(configString) { NavBarConfig.deserialize(configString) } - - Column( - modifier = Modifier - .wrapContentHeight(), - ) { - Text( - text = stringResource(R.string.subtitle_settingsButtonOrder), - style = CodeTheme.typography.textSmall, - color = CodeTheme.colors.textSecondary, - modifier = Modifier.padding(horizontal = CodeTheme.dimens.inset), - ) - - Box( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = CodeTheme.dimens.inset) - .padding(top = CodeTheme.dimens.grid.x1) - .clip(CodeTheme.shapes.large) - .background(White05) - .padding(vertical = CodeTheme.dimens.grid.x4), - contentAlignment = Alignment.Center, - ) { - NavigationBar( - // Lab reorder preview is v1-only (the v2 bar is a fixed tab set). - state = NavigationBarState(isNewUi = false, config = config), - onOrderChanged = { newOrder -> - val updated = config.copy(order = newOrder) - featureFlags.setOption(FeatureFlag.NavBar, updated.serialize()) - }, - ) - } - - SectionHeader(stringResource(R.string.title_settingsSectionGiveButtonLabel)) - - CodeSegmentedControl( - options = GiveButtonLabel.entries, - selected = config.giveButtonLabel, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = CodeTheme.dimens.inset) - .padding(bottom = CodeTheme.dimens.grid.x3) - .navigationBarsPadding(), - mapper = { option -> - Text(text = stringResource(option.labelRes)) - }, - onSelectionChanged = { label -> - val updated = config.copy(giveButtonLabel = label) - featureFlags.setOption(FeatureFlag.NavBar, updated.serialize()) - }, - ) - } -} 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 343aaaadc4..28180d42ac 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 @@ -105,8 +105,7 @@ import kotlin.time.Duration.Companion.milliseconds * [PermissionsPhaseFlowHost]. * ² Display-name entry is shown only when no display name is set. It reuses the * UpdateUserProfile subflow, whose `target` replaces the stack with the permissions phase. - * ³ Home is the same route the app launches on — the Wallet tab under [FeatureFlag.NewUi], the - * Scanner under v1. See [homeRoute]. + * ³ Home is the same route the app launches on — the Wallet tab. See [homeRoute]. */ @Composable fun OnboardingFlowScreen( @@ -152,10 +151,8 @@ private fun PermissionsPhaseFlowHost( val userManager = LocalUserManager.current val contactPickerMode by featureFlags.observe(FeatureFlag.ContactPickerMode).collectAsStateWithLifecycle() - // Onboarding releases to the same home the app launches on: the Wallet tab under v2, the - // Scanner under v1. - val isNewUi by featureFlags.observe(FeatureFlag.NewUi).collectAsStateWithLifecycle() - val home = homeRoute(isNewUi) + // Onboarding releases to the same home the app launches on: the Wallet tab. + val home = homeRoute val permissionsSteps = buildList { if (!route.skipContacts && !contactPickerMode) add(OnboardingStep.ContactPermission) diff --git a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenContent.kt b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenContent.kt index a3e73a51a3..5bfb64394b 100644 --- a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenContent.kt +++ b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenContent.kt @@ -8,10 +8,8 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Text @@ -25,7 +23,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp @@ -33,21 +30,14 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flipcash.app.bills.ScannableRenderer import com.flipcash.app.bills.components.cards.LocalTipCardBaseAlpha import com.flipcash.app.bills.components.cards.LocalTipCardColor -import com.flipcash.app.core.AppRoute import com.flipcash.app.core.bill.Scannable import com.flipcash.app.core.navigation.LocalTabBarPadding -import com.flipcash.app.core.ui.TileButton -import com.flipcash.app.featureflags.FeatureFlag -import com.flipcash.app.featureflags.LocalFeatureFlags import com.flipcash.app.menu.MenuList import com.flipcash.app.menu.internal.MenuScreenViewModel.Event import com.flipcash.app.session.LocalSessionController import com.flipcash.app.updates.LocalAppUpdater import com.flipcash.features.menu.R -import com.getcode.navigation.core.CodeNavigator -import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.theme.CodeTheme -import com.getcode.ui.components.AppBarDefaults import com.getcode.ui.components.AppBarWithTitle import com.getcode.ui.core.noRippleClickable import com.getcode.ui.theme.CodeScaffold @@ -58,14 +48,7 @@ import kotlinx.coroutines.flow.onEach @Composable internal fun MenuScreenContent(viewModel: MenuScreenViewModel) { val state by viewModel.stateFlow.collectAsStateWithLifecycle() - val navigator = LocalCodeNavigator.current val appUpdater = LocalAppUpdater.current - val features = LocalFeatureFlags.current - // v2: this screen is the "You" tab (card + share + settings). v1: it's the Settings sheet. - // Collect, don't snapshot: observe() is a StateFlow seeded with the flag's DEFAULT (NewUi - // defaults to true) until DataStore emits the stored value. Reading `.value` inside a remember - // froze that default, so a v1 build rendered the v2 "You" screen. - val isNewUi by features.observe(FeatureFlag.NewUi).collectAsStateWithLifecycle() LaunchedEffect(Unit) { viewModel.eventFlow @@ -78,24 +61,11 @@ internal fun MenuScreenContent(viewModel: MenuScreenViewModel) { topBar = { AppBarWithTitle( modifier = Modifier.fillMaxWidth(), - title = stringResource(if (isNewUi) R.string.title_you else R.string.title_settings), + title = stringResource(R.string.title_you), titleAlignment = Alignment.CenterHorizontally, - // The You tab is entered by tab selection, so it has no Close; the v1 sheet keeps it. - endContent = { if (!isNewUi) AppBarDefaults.Close { navigator.hide() } }, + // The You tab is entered by tab selection, so it has no Close. ) }, - bottomBar = { - // v1 pins the version footer above the nav bar; v2 scrolls it with the content (footer slot). - if (!isNewUi) { - VersionFooter( - viewModel = viewModel, - state = state, - modifier = Modifier - .navigationBarsPadding() - .padding(bottom = CodeTheme.dimens.grid.x3), - ) - } - } ) { padding -> MenuList( modifier = Modifier @@ -103,34 +73,28 @@ internal fun MenuScreenContent(viewModel: MenuScreenViewModel) { .padding(padding), items = state.items, header = { - if (isNewUi) { - YouHeader( - card = state.tipCard, - onShare = { viewModel.dispatchEvent(Event.ShareTipCard) }, - ) - } else { - MoneyTiles(viewModel, navigator) - } + YouHeader( + card = state.tipCard, + onShare = { viewModel.dispatchEvent(Event.ShareTipCard) }, + ) }, footer = { - if (isNewUi) { - // Scrolls with the list, so it needs its own breathing room off the last row's - // divider. No navigationBarsPadding here — the reserved tab-bar inset below - // already clears the system bar (the bar measures itself with that padding in). - VersionFooter( - viewModel = viewModel, - state = state, - modifier = Modifier.padding( - top = CodeTheme.dimens.grid.x6, - bottom = CodeTheme.dimens.grid.x3, - ), - ) - } + // Scrolls with the list, so it needs its own breathing room off the last row's + // divider. No navigationBarsPadding here — the reserved tab-bar inset below + // already clears the system bar (the bar measures itself with that padding in). + VersionFooter( + viewModel = viewModel, + state = state, + modifier = Modifier.padding( + top = CodeTheme.dimens.grid.x6, + bottom = CodeTheme.dimens.grid.x3, + ), + ) }, - // v2's tab bar is a hoisted overlay drawn ABOVE this content, so reserve its height as + // The tab bar is a hoisted overlay drawn ABOVE this content, so reserve its height as // bottom content padding — the list then scrolls clear of the bar instead of running // under it (the version footer was landing behind it). Per-entry via LocalTabBarPadding, - // which is only non-zero for tab homes. v1 has no such bar. + // which is only non-zero for tab homes. contentPadding = PaddingValues( top = CodeTheme.dimens.grid.x3, bottom = LocalTabBarPadding.current.calculateBottomPadding(), @@ -198,37 +162,6 @@ private fun YouHeader(card: Scannable.TipCard?, onShare: () -> Unit) { } } -/** v1 Settings-sheet header: the Add Money / Withdraw tiles (removed from the v2 You tab). */ -@Composable -private fun MoneyTiles( - viewModel: MenuScreenViewModel, - navigator: CodeNavigator, -) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = CodeTheme.dimens.grid.x3), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3), - ) { - TileButton( - modifier = Modifier.weight(1f), - text = stringResource(R.string.action_addMoney), - icon = painterResource(R.drawable.ic_menu_deposit) - ) { - viewModel.dispatchEvent(Event.PresentDepositOptions) - } - - TileButton( - modifier = Modifier.weight(1f), - text = stringResource(R.string.action_withdrawMoney), - icon = painterResource(R.drawable.ic_menu_withdraw) - ) { - navigator.push(AppRoute.Transfers.Withdrawal()) - } - } -} - /** The "Version … • Build …" footer; its repeated tap toggles beta access (see the ViewModel). */ @Composable private fun VersionFooter( 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 09c374aced..f474bf293f 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 @@ -5,29 +5,23 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager -import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flipcash.app.analytics.rememberAnalytics import com.flipcash.app.core.AppRoute import com.flipcash.app.core.AppRoute.Token.* import com.flipcash.app.core.extensions.navigateAll -import com.flipcash.app.core.extensions.openAsSheet import com.flipcash.app.core.navigation.DeeplinkType -import com.flipcash.app.featureflags.FeatureFlag -import com.flipcash.app.featureflags.LocalFeatureFlags import com.flipcash.app.router.LocalRouter import com.flipcash.app.scanner.internal.bills.ScannableContainer import com.flipcash.app.session.LocalSessionController import com.getcode.libs.code.detection.CodeScanResult import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.ui.biometrics.LocalBiometricsState -import com.getcode.ui.components.OnLifecycleEvent import com.getcode.ui.scanner.CodeScanner import com.getcode.ui.scanner.NoCamerasAvailableException import com.getcode.ui.utils.KeepScreenOn @@ -35,20 +29,14 @@ import com.getcode.util.vibration.LocalVibrator import com.getcode.utils.ErrorUtils import com.kik.kikx.kikcodes.implementation.KikCodeResult import dev.theolm.rinku.DeepLink -import timber.log.Timber @Composable internal fun Scanner() { val router = LocalRouter.current!! val navigator = LocalCodeNavigator.current val session = LocalSessionController.current!! - val state by session.state.collectAsStateWithLifecycle() val billState by session.billState.collectAsStateWithLifecycle() val analytics = rememberAnalytics() - val isNewUi by LocalFeatureFlags.current.observe(FeatureFlag.NewUi) - .collectAsStateWithLifecycle() - - var isPaused by remember { mutableStateOf(false) } var previewing by remember { mutableStateOf(null) @@ -64,9 +52,6 @@ internal fun Scanner() { val vibrator = LocalVibrator.current - var isPinching by remember { mutableStateOf(false) } - var zoomRatio by remember { mutableFloatStateOf(1f) } - LaunchedEffect(biometricsState, previewing) { if (previewing == true) { focusManager.clearFocus() @@ -81,36 +66,11 @@ internal fun Scanner() { @SuppressLint("LocalContextGetResourceValueCall") ScannableContainer( - isPaused = isPaused, - isPinching = isPinching, - zoomRatio = zoomRatio, - onAction = { - when (it) { - ScannerDecorItem.Give -> { - // only allow navigation to give when there is something to give - // Zero balance -> prompt to add money; otherwise the user has funds - // but nothing giveable -> prompt to discover a currency. - // presentDepositOptions picks the right prompt based on balance. - if (!state.hasGiveableBalance) { - session.presentDepositOptions { route -> - navigator.openAsSheet(route) - } - - return@ScannableContainer - } - } - else -> Unit - } - navigator.openAsSheet(it.screen) - }, scannerView = { CodeScanner( scanningEnabled = previewing == true, cameraGesturesEnabled = true, - onPinchStateChanged = { pinching, zoom -> - isPinching = pinching - zoomRatio = zoom - }, + onPinchStateChanged = { _, _ -> }, onPreviewStateChanged = { cameraAvailable = true previewing = it @@ -145,7 +105,7 @@ internal fun Scanner() { else -> emptyList() } if (routes.isNotEmpty()) { - navigator.navigateAll(routes, isNewUi = isNewUi) + navigator.navigateAll(routes) } } is DeeplinkType.Login -> Unit @@ -170,31 +130,6 @@ internal fun Scanner() { }, ) - OnLifecycleEvent { _, event -> - when (event) { - Lifecycle.Event.ON_START -> { - Timber.d("onStart") - isPaused = false - } - - Lifecycle.Event.ON_STOP -> { - Timber.d("onStop") - } - - Lifecycle.Event.ON_PAUSE -> { - Timber.d("onPause") - isPaused = true - } - - Lifecycle.Event.ON_RESUME -> { - Timber.d("onResume") - isPaused = false - } - - else -> Unit - } - } - DisposableEffect(LocalCodeNavigator.current) { onDispose { previewing = false diff --git a/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/ScannerDecorItem.kt b/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/ScannerDecorItem.kt deleted file mode 100644 index 0a254689dc..0000000000 --- a/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/ScannerDecorItem.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.flipcash.app.scanner.internal - -import com.flipcash.app.core.AppRoute -import com.flipcash.app.core.tokens.TokenPurpose - -sealed class ScannerDecorItem(val screen: AppRoute) { - data object Give : ScannerDecorItem(AppRoute.Sheets.Give()) - - data object Wallet : ScannerDecorItem(AppRoute.Sheets.Wallet) - data object Menu : ScannerDecorItem(AppRoute.Sheets.Menu) - data object Logo: ScannerDecorItem(AppRoute.Sheets.ShareApp) - data object Discover: ScannerDecorItem(AppRoute.Token.Discovery) - data object Tips: ScannerDecorItem(AppRoute.Sheets.Tips()) -} \ No newline at end of file diff --git a/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/bills/ScannableContainer.kt b/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/bills/ScannableContainer.kt index e7f220220c..ffe44be5eb 100644 --- a/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/bills/ScannableContainer.kt +++ b/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/bills/ScannableContainer.kt @@ -1,8 +1,5 @@ package com.flipcash.app.scanner.internal.bills -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable @@ -18,8 +15,6 @@ import androidx.compose.ui.platform.testTag import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flipcash.app.core.android.extensions.launchAppSettings -import com.flipcash.app.scanner.internal.ScannerDecorItem -import com.flipcash.app.scanner.internal.ui.components.DecorView import com.flipcash.app.session.LocalSessionController import com.flipcash.app.updates.LocalAppUpdater import com.flipcash.features.scanner.R @@ -33,19 +28,15 @@ import com.getcode.util.permissions.PermissionResult import com.getcode.util.permissions.rememberCameraPermission /** - * The scanner surface: the camera preview (via [scannerView]) plus the HUD ([DecorView]). Bills are - * no longer drawn here — a presented bill renders at the app root - * ([com.flipcash.app.bills.BillOverlay]) so it can appear over any screen. This container only reads - * [com.flipcash.app.session.SessionController.billState] to hide its HUD while a bill is up. + * The scanner surface: the camera preview (via [scannerView]) and its permission states. Bills are + * not drawn here — a presented bill renders at the app root + * ([com.flipcash.app.bills.BillOverlay]) so it can appear over any screen. Scanner chrome is the + * hoisted app nav bar, not an in-screen HUD. */ @Composable internal fun ScannableContainer( modifier: Modifier = Modifier, - isPaused: Boolean, - isPinching: Boolean = false, - zoomRatio: Float = 1f, scannerView: @Composable () -> Unit, - onAction: (ScannerDecorItem) -> Unit ) { val session = LocalSessionController.current!! val context = LocalContext.current @@ -74,7 +65,6 @@ internal fun ScannableContainer( } val state by session.state.collectAsStateWithLifecycle() - val billState by session.billState.collectAsStateWithLifecycle() val autoStart = state.autoStartCamera == true var cameraStarted by remember { mutableStateOf(autoStart) } @@ -132,22 +122,5 @@ internal fun ScannableContainer( } } } - - // Hide the HUD while a bill is presented — the bill now renders at the app root, above this. - AnimatedVisibility( - visible = billState.bill == null, - enter = fadeIn(), - exit = fadeOut(), - modifier = Modifier.fillMaxSize() - ) { - DecorView( - state = state, - billState = billState, - isPaused = isPaused, - isPinching = isPinching, - zoomRatio = zoomRatio, - onAction = onAction - ) - } } } diff --git a/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/ui/components/DecorView.kt b/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/ui/components/DecorView.kt deleted file mode 100644 index 6ee5921e4e..0000000000 --- a/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/ui/components/DecorView.kt +++ /dev/null @@ -1,192 +0,0 @@ -package com.flipcash.app.scanner.internal.ui.components - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.tween -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.foundation.Image -import androidx.compose.foundation.background - -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.WindowInsets -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.navigationBars -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.statusBarsPadding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.windowInsetsPadding -import androidx.compose.foundation.layout.wrapContentSize -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.Icon -import androidx.compose.material.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.flipcash.app.bill.customization.LocalBillPlaygroundController -import com.flipcash.app.core.bill.BillState -import com.flipcash.app.featureflags.FeatureFlag -import com.flipcash.app.featureflags.LocalFeatureFlags -import com.flipcash.app.scanner.internal.ScannerDecorItem -import com.flipcash.app.session.SessionState -import com.flipcash.features.scanner.R -import com.getcode.theme.CodeTheme -import com.getcode.theme.xxl -import com.getcode.ui.components.Pill -import com.getcode.ui.core.noRippleClickable -import com.getcode.ui.core.unboundedClickable -import com.getcode.utils.network.LocalNetworkObserver - -@Composable -internal fun DecorView( - state: SessionState, - billState: BillState, - isPaused: Boolean, - modifier: Modifier = Modifier, - isPinching: Boolean = false, - zoomRatio: Float = 1f, - onAction: (ScannerDecorItem) -> Unit, -) { - val features = LocalFeatureFlags.current - val billPlayground = LocalBillPlaygroundController.current - val playgroundState by billPlayground.state.collectAsStateWithLifecycle() - - val isNewUi by features.observe(FeatureFlag.NewUi).collectAsStateWithLifecycle() - - val isUsingPlayground by remember( - playgroundState.isCustomizing, - playgroundState.context, - ) { - derivedStateOf { - playgroundState.isCustomizing && playgroundState.context.renderAsOverlay - } - } - - AnimatedVisibility(!isUsingPlayground && !isNewUi) { - Box( - modifier = Modifier - .fillMaxSize() - .then(modifier) - ) { - Image( - modifier = Modifier - .statusBarsPadding() - .padding(vertical = CodeTheme.dimens.grid.x3) - .padding(horizontal = CodeTheme.dimens.grid.x3) - .align(Alignment.TopStart) - .width(CodeTheme.dimens.staticGrid.x18) - .noRippleClickable { - onAction(ScannerDecorItem.Logo) - }.testTag("flipcash_logo"), - painter = painterResource(R.drawable.ic_flipcash_logo_w_name), - contentDescription = "Tap to share the app", - ) - - Column( - modifier = Modifier - .statusBarsPadding() - .padding(vertical = CodeTheme.dimens.grid.x2) - .padding(horizontal = CodeTheme.dimens.grid.x3) - .align(Alignment.TopEnd), - horizontalAlignment = Alignment.End, - verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.inset) - ) { - Image( - modifier = Modifier - .clip(CircleShape) - .unboundedClickable { - onAction(ScannerDecorItem.Menu) - }.testTag("menu_button"), - painter = painterResource(R.drawable.ic_home_options), - contentDescription = "", - ) - } - - AnimatedVisibility( - modifier = Modifier - .align(Alignment.TopCenter) - .statusBarsPadding() - .padding(top = CodeTheme.dimens.grid.x9), - visible = isPinching, - enter = fadeIn(tween(150)), - exit = fadeOut(tween(250)), - ) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.TopCenter, - ) { - Pill( - text = "${"%.1f".format(zoomRatio)}x", - textStyle = CodeTheme.typography.textSmall.copy( - fontWeight = FontWeight.Bold - ), - shape = CodeTheme.shapes.xxl, - ) - } - } - - Column(modifier = Modifier.align(Alignment.BottomCenter)) { - val networkState by LocalNetworkObserver.current.state.collectAsStateWithLifecycle() - - AnimatedVisibility( - modifier = Modifier - .align(Alignment.CenterHorizontally), - visible = state.showNetworkOffline && !networkState.connected, - enter = fadeIn(animationSpec = tween(500, 100)), - exit = fadeOut(animationSpec = tween(500, 100)), - ) { - Row( - modifier = Modifier - .wrapContentSize() - .clip(CodeTheme.shapes.xxl) - .background(CodeTheme.colors.error) - .padding( - horizontal = CodeTheme.dimens.grid.x2, - vertical = CodeTheme.dimens.grid.x1 - ), - horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.staticGrid.x1), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - modifier = Modifier.size(16.dp), - painter = painterResource(id = R.drawable.ic_wifi_slash), - contentDescription = null - ) - Text( - text = stringResource(id = R.string.title_badge_no_connection), - color = Color.White, - style = CodeTheme.typography.caption - ) - } - } - - ScannerNavigationBar( - modifier = Modifier - .windowInsetsPadding(WindowInsets.navigationBars) - .padding(bottom = CodeTheme.dimens.grid.x3), - state = state, - billState = billState, - isPaused = isPaused, - onAction = onAction - ) - } - } - } -} \ No newline at end of file diff --git a/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/ui/components/ScannerNavigationBar.kt b/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/ui/components/ScannerNavigationBar.kt deleted file mode 100644 index 1fbe7ae691..0000000000 --- a/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/ui/components/ScannerNavigationBar.kt +++ /dev/null @@ -1,66 +0,0 @@ -package com.flipcash.app.scanner.internal.ui.components - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.flipcash.app.core.bill.BillState -import com.flipcash.app.core.navigation.NavBarButton -import com.flipcash.app.core.navigation.NavBarConfig -import com.flipcash.app.core.ui.NavigationBar -import com.flipcash.app.core.ui.NavigationBarState -import com.flipcash.app.core.ui.rememberNavigationBarState -import com.flipcash.app.featureflags.FeatureFlag -import com.flipcash.app.featureflags.LocalFeatureFlags -import com.flipcash.app.scanner.internal.ScannerDecorItem -import com.flipcash.app.session.SessionState - -@Composable -internal fun ScannerNavigationBar( - modifier: Modifier = Modifier, - state: SessionState = SessionState(), - billState: BillState = BillState.Default, - isPaused: Boolean = false, - onAction: (ScannerDecorItem) -> Unit = { } -) { - val featureFlags = LocalFeatureFlags.current - // v2 hoists the nav bar to the app root (AppNavigationBar); v1 keeps it in-screen. - val newUi by featureFlags.observe(FeatureFlag.NewUi).collectAsStateWithLifecycle() - if (newUi) return - - val navBarConfigString by featureFlags - .getOption(FeatureFlag.NavBar) - .collectAsStateWithLifecycle() - val config = remember(navBarConfigString) { - NavBarConfig.deserialize(navBarConfigString) - } - - val navBarState = rememberNavigationBarState( - isNewUi = false, - config = config, - tipUnreadCount = state.tipsUnreadCount, - showToast = billState.showToast && billState.toast != null, - toastText = billState.toast?.formattedAmount, - isPaused = isPaused, - ) - - NavigationBar( - modifier = modifier, - state = navBarState, - onButtonClick = { button -> - val item = when (button) { - NavBarButton.Give -> ScannerDecorItem.Give - NavBarButton.Wallet -> ScannerDecorItem.Wallet - NavBarButton.Discover -> ScannerDecorItem.Discover - NavBarButton.Tips -> ScannerDecorItem.Tips - NavBarButton.Chats -> null - NavBarButton.TipCard -> null - NavBarButton.Scanner -> null - } - if (item != null) { - onAction(item) - } - }, - ) -} diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/TipCardScreen.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/TipCardScreen.kt index 5b0e868f40..d971114d75 100644 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/TipCardScreen.kt +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/TipCardScreen.kt @@ -1,140 +1,34 @@ package com.flipcash.app.tipping -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.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.navigationBarsPadding -import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.requiredSize -import androidx.compose.material3.Icon -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider 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.Color -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flipcash.app.bills.ScannableRenderer import com.flipcash.app.bills.components.cards.LocalTipCardBaseAlpha import com.flipcash.app.bills.components.cards.LocalTipCardColor import com.flipcash.app.core.bill.Scannable -import com.flipcash.app.core.tipping.TipResult -import com.flipcash.app.core.tipping.TipStep -import com.flipcash.app.featureflags.FeatureFlag -import com.flipcash.app.featureflags.LocalFeatureFlags import com.flipcash.app.tipping.internal.TipFlowViewModel -import com.flipcash.app.tipping.internal.TipFlowViewModel.Event -import com.flipcash.features.tipping.R import com.getcode.navigation.flow.flowSharedViewModel -import com.getcode.navigation.flow.rememberFlowNavigator -import com.getcode.theme.CodeTheme -import com.getcode.ui.components.AppBarWithTitle -import com.getcode.ui.components.CircularIconButton -import com.getcode.ui.theme.CodeScaffold /** * The user's own tip card — a step in the tipping [TippingFlowScreen] flow, so it shares the flow's - * [TipFlowViewModel]. Only the chrome differs by [FeatureFlag.NewUi]: - * - **v2**: the post-profile-setup landing (the flow seeded at TipCard) — the card full-bleed, no - * chrome. The primary home for the card is now the "You" tab (the menu), which also owns settings. - * - **v1**: a sheet step — title bar with back, and a Share action. + * [TipFlowViewModel]. It is the post-profile-setup landing (the flow seeded at TipCard): the card + * full-bleed, no chrome. The primary home for the card is the "You" tab (the menu), which also owns + * settings. */ @Composable fun TipCardScreen() { - val features = LocalFeatureFlags.current - // Collect rather than snapshot `.value` — the flow is seeded with the flag's default until - // DataStore emits, so a remembered read freezes the default (see MenuScreenContent). - val isNewUi by features.observe(FeatureFlag.NewUi).collectAsStateWithLifecycle() - val viewModel = flowSharedViewModel() val state by viewModel.stateFlow.collectAsStateWithLifecycle() - if (isNewUi) { - // Post-profile-setup landing: the user's newly created tip card, full-bleed. Settings now - // live in the "You" tab (the menu), so this screen no longer carries a settings hamburger. - Box(modifier = Modifier.fillMaxSize()) { - TipCardArt(card = state.tipCard, modifier = Modifier.fillMaxSize()) - } - } else { - val flowNavigator = rememberFlowNavigator() - CodeScaffold( - topBar = { - Column( - verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x10), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - AppBarWithTitle( - title = stringResource(R.string.title_myTipCard), - titleAlignment = Alignment.CenterHorizontally, - onBackIconClicked = { flowNavigator.back() }, - ) - Text( - text = stringResource(R.string.subtitle_myTipCard), - style = CodeTheme.typography.textLarge, - color = CodeTheme.colors.textMain, - ) - } - }, - bottomBar = { - Row( - modifier = Modifier.fillMaxWidth() - .navigationBarsPadding() - .padding(bottom = CodeTheme.dimens.grid.x3), - horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x5, Alignment.CenterHorizontally), - verticalAlignment = Alignment.CenterVertically, - ) { - Column( - modifier = Modifier.padding(bottom = CodeTheme.dimens.grid.x3), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3), - ) { - CircularIconButton( - imageSize = CodeTheme.dimens.staticGrid.x6, - buttonSize = CodeTheme.dimens.grid.x12, - onClick = { - viewModel.dispatchEvent(Event.ShareTipCard) - } - ) { size -> - Icon( - painter = painterResource(R.drawable.ic_remote_send), - contentDescription = null, - tint = Color.White, - // The send glyph is bottom-heavy (wide tray below a thin arrow), - // so geometric centering leaves it sitting visually low in the - // circle. Nudge it up slightly to optically center it. - modifier = Modifier - .requiredSize(size) - .offset(y = (-1.5).dp), - ) - } - Text( - text = stringResource(R.string.action_share), - style = CodeTheme.typography.textSmall, - color = CodeTheme.colors.textSecondary, - ) - } - } - } - ) { padding -> - Column( - modifier = Modifier - .fillMaxSize() - .padding(padding), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center - ) { - TipCardArt(card = state.tipCard) - } - } + Box(modifier = Modifier.fillMaxSize()) { + TipCardArt(card = state.tipCard, modifier = Modifier.fillMaxSize()) } } diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/TipsScreen.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/TipsScreen.kt index 4b01564a17..438447a31f 100644 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/TipsScreen.kt +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/TipsScreen.kt @@ -1,7 +1,6 @@ package com.flipcash.app.tipping import androidx.compose.foundation.Image -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -18,6 +17,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -29,9 +29,6 @@ import com.flipcash.app.core.AppRoute import com.flipcash.app.core.chat.ChatIdentifier import com.flipcash.app.core.data.isLoaded import com.flipcash.app.core.navigation.LocalTabBarPadding -import com.flipcash.app.core.tipping.TipStep -import com.flipcash.app.featureflags.FeatureFlag -import com.flipcash.app.featureflags.LocalFeatureFlags import com.flipcash.app.theme.FlipcashThemeWrapper import com.flipcash.app.tipping.internal.TipFlowViewModel import com.flipcash.app.tipping.internal.components.TipChatRow @@ -40,84 +37,43 @@ import com.flipcash.shared.chat.ui.ConversationReference import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.navigation.flow.flowSharedViewModel import com.getcode.theme.CodeTheme -import com.getcode.ui.components.AppBarDefaults import com.getcode.ui.components.AppBarWithTitle -import com.getcode.ui.theme.ButtonState -import com.getcode.ui.theme.CodeButton import com.getcode.ui.theme.CodeScaffold /** * The tip DM conversation list — always a step in the tipping [TippingFlowScreen] flow, so it shares - * the flow's [TipFlowViewModel]. Only the chrome differs by [FeatureFlag.NewUi]: - * - **v2**: the "Chats" root tab — a flush large title over the list, no dismiss affordance (the - * root nav bar is the chrome); the tip card lives on its own tab. - * - **v1**: the "Tips" sheet step — centered title with a Close, and a sticky "Show Tip Card" button - * that advances the flow. + * the flow's [TipFlowViewModel]. It is the "Chats" root tab: a flush large title over the list, no + * dismiss affordance (the root nav bar is the chrome); the tip card lives on its own tab. */ @Composable fun TipsScreen() { - val features = LocalFeatureFlags.current - // Collect rather than snapshot `.value` — the flow is seeded with the flag's default until - // DataStore emits, so a remembered read freezes the default (see MenuScreenContent). - val isNewUi by features.observe(FeatureFlag.NewUi).collectAsStateWithLifecycle() - val viewModel = flowSharedViewModel() val state by viewModel.stateFlow.collectAsStateWithLifecycle() val navigator = LocalCodeNavigator.current CodeScaffold( topBar = { - if (isNewUi) { - AppBarWithTitle( - title = stringResource(R.string.title_chats), - titleTextStyle = CodeTheme.typography.screenTitleLarge, - ) - } else { - AppBarWithTitle( - title = stringResource(R.string.title_tips), - titleAlignment = Alignment.CenterHorizontally, - endContent = { - AppBarDefaults.Close { navigator.hide() } - } - ) - } + AppBarWithTitle( + title = stringResource(R.string.title_chats), + titleTextStyle = CodeTheme.typography.screenTitleLarge, + ) } ) { padding -> val chats = state.tipChats LazyColumn( modifier = Modifier .fillMaxSize() + // Scroll anchor for UI tests: `send_contact_row` addresses a single row, this + // addresses the scrollable list itself. + .testTag("chat_list") .padding(padding), - // Clears the hoisted v2 tab bar (empty for v1, which has no tab bar): keeps the last row - // reachable and centers the empty state in the space the bar leaves visible. + // Clears the hoisted tab bar: keeps the last row reachable and centers the empty state + // in the space the bar leaves visible. contentPadding = LocalTabBarPadding.current, ) { - // v1 surfaces the tip card via a button here; v2 has a dedicated tip-card tab instead. - if (!isNewUi) { - stickyHeader { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = CodeTheme.dimens.inset) - .background(CodeTheme.colors.background), - contentAlignment = Alignment.Center - ) { - CodeButton( - modifier = Modifier.fillMaxWidth() - .padding(horizontal = CodeTheme.dimens.inset), - text = stringResource(R.string.action_showTipCard), - buttonState = ButtonState.Filled, - onClick = { - navigator.navigate(TipStep.TipCard) - }, - ) - } - } - } - - // v2 only: once the feed has loaded and there's nothing to show, the list is replaced by - // a centered prompt. v1's sheet keeps its bare list under the Tip Card button. - if (isNewUi && chats.isLoaded() && chats.data.isEmpty()) { + // Once the feed has loaded and there's nothing to show, the list is replaced by a + // centered prompt. + if (chats.isLoaded() && chats.data.isEmpty()) { item { NoChatsYet(Modifier.fillParentMaxSize()) } } else { tipChatItems(chats.dataOrNull.orEmpty()) { chat -> diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/CurrencyInfoExpansion.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/CurrencyInfoExpansion.kt index 8f5533e576..11ec530970 100644 --- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/CurrencyInfoExpansion.kt +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/CurrencyInfoExpansion.kt @@ -39,6 +39,7 @@ import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.Velocity import androidx.compose.ui.unit.dp @@ -244,7 +245,10 @@ fun CurrencyInfoExpansion( }.collect { controller.pullOffset = it } } - Box(modifier = modifier.fillMaxSize()) { + // Same screen-root anchor the PUSHED currency-info screen gets from `annotatedEntry`. In v2 a + // wallet card opens this overlay instead of pushing that screen, so UI tests would otherwise + // lose their "currency info is showing" anchor on the most common path into it. + Box(modifier = modifier.fillMaxSize().testTag("token_info_screen")) { // Only the card rides the overscroll down; the detail + chrome stay put and just fade — faster // than the pull travels, so the screen is mostly gone by the time the card is barely moved (iOS). // Fade off the SAME effective offset the card rides — pullPx while dragging, then releasedOffset*p diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/SwapFlowScreen.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/SwapFlowScreen.kt index 898c5feb6c..9930d5a205 100644 --- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/SwapFlowScreen.kt +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/SwapFlowScreen.kt @@ -107,7 +107,9 @@ private fun swapEntryProvider( // SwapEntryScreen reads only the inner LocalCodeNavigator; its cross-boundary result nav // resolves to the app navigator via the dispatcher, so a plain annotatedEntry is correct. - annotatedEntry { step -> + // Explicitly tagged: the derived id would be `entry_screen`, which says nothing about which + // flow it belongs to. `maestro/buy.yaml` / `maestro/sell.yaml` anchor on `swap_screen`. + annotatedEntry(testTag = "swap_screen") { step -> SwapEntryScreen(step.purpose, step.initialAmount) } annotatedEntry { key -> diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenInfoScreen.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenInfoScreen.kt index 1a10d8e5ef..c5ec4ec0ff 100644 --- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenInfoScreen.kt +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenInfoScreen.kt @@ -3,7 +3,6 @@ package com.flipcash.app.tokens import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.asPaddingValues @@ -37,9 +36,6 @@ import com.flipcash.app.analytics.Button import com.flipcash.app.analytics.rememberAnalytics import com.flipcash.app.core.AppRoute import com.flipcash.app.core.tokens.SwapResult -import com.flipcash.app.core.ui.TokenIconWithName -import com.flipcash.app.featureflags.FeatureFlag -import com.flipcash.app.featureflags.LocalFeatureFlags import com.flipcash.app.tokens.internal.TokenInfoScreen import com.flipcash.app.tokens.internal.components.info.CurrencyInfoTitlePill import com.flipcash.app.tokens.ui.TokenInfoViewModel @@ -76,13 +72,9 @@ fun TokenInfoScreen( val viewModel = hiltViewModel() val state by viewModel.stateFlow.collectAsStateWithLifecycle() - val features = LocalFeatureFlags.current - // Collect rather than snapshot `.value` — the flow is seeded with the flag's default until - // DataStore emits, so a remembered read freezes the default (see MenuScreenContent). - val isNewUi by features.observe(FeatureFlag.NewUi).collectAsStateWithLifecycle() val listState = rememberLazyListState() - // v2: the title is a leading "Liquid Glass" pill that fades in once the hero card's own title has + // The title is a leading "Liquid Glass" pill that fades in once the hero card's own title has // scrolled up under the bar. Approximate that point by the first item's scroll offset. val revealThresholdPx = with(LocalDensity.current) { CodeTheme.dimens.staticGrid.x12.toPx() } val showPill by remember(listState, revealThresholdPx) { @@ -96,34 +88,26 @@ fun TokenInfoScreen( label = "titlePill", ) - // For v2 the app bar chrome (back / title pill / share) is frosted "liquid glass" over the content + // The app bar chrome (back / title pill / share) is frosted "liquid glass" over the content // scrolling beneath it — [haze] is that content's blur source. val appBar: @Composable (HazeState?) -> Unit = { haze -> AppBarWithTitle( titleContent = { state.token.dataOrNull?.let { token -> - if (isNewUi) { - CurrencyInfoTitlePill( - token = token, - marketCap = state.marketCap, - progress = pillProgress, - hazeState = haze, - ) - } else { - TokenIconWithName( - token = token, - imageSize = CodeTheme.dimens.staticGrid.x5, - spacing = CodeTheme.dimens.grid.x1, - ) - } + CurrencyInfoTitlePill( + token = token, + marketCap = state.marketCap, + progress = pillProgress, + hazeState = haze, + ) } }, - titleAlignment = if (isNewUi) Alignment.Start else Alignment.CenterHorizontally, + titleAlignment = Alignment.Start, onBackIconClicked = { navigator.pop() }, - // v2 currency-info is a modal dismiss, not a true back nav — lead with a close (✕). But when + // Currency-info is a modal dismiss, not a true back nav — lead with a close (✕). But when // it was PUSHED onto the stack (e.g. drilled into from token discovery) it IS a back nav, so // lead with a back arrow instead. - leadingDismiss = isNewUi && !asPush, + leadingDismiss = !asPush, hazeState = haze, endContent = { state.token.dataOrNull?.let { @@ -138,62 +122,52 @@ fun TokenInfoScreen( ) } - if (isNewUi) { - // Overlay: content fills behind the app bar (hazeSource for the frosted chrome) and is inset by - // the bar height, measured BEFORE the content in the same layout pass (OverlayTopBarScaffold), - // so the hero card sits correctly on the very first frame — no settle/jump. The bar draws its - // own bg->transparent scrim (chat-style) so content fades as it scrolls under it. - val hazeState = rememberHazeState() - val bottomInset = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + // Overlay: content fills behind the app bar (hazeSource for the frosted chrome) and is inset by + // the bar height, measured BEFORE the content in the same layout pass (OverlayTopBarScaffold), + // so the hero card sits correctly on the very first frame — no settle/jump. The bar draws its + // own bg->transparent scrim (chat-style) so content fades as it scrolls under it. + val hazeState = rememberHazeState() + val bottomInset = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() - OverlayTopBarScaffold( - topBar = { - // Fade the status-bar strip plus HALF the app-bar row (behind the chrome): the hero card - // dims as it scrolls up under the bar (matching iOS) while staying vibrant below the bar's - // midline. [appBarHeight] is measured on the app bar alone (status bar excluded), so the - // scrim = status bar + half the app bar. The scrim never grows the content inset (the - // scaffold measures the full bar), so there's no jump. - var appBarHeight by remember { mutableStateOf(0.dp) } - val statusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() - Box { - Box( - modifier = Modifier - .fillMaxWidth() - .height(statusBarHeight + appBarHeight * 0.5f) - .background( - Brush.verticalGradient( - 0f to CodeTheme.colors.background, - 1f to Color.Transparent, - ) + OverlayTopBarScaffold( + topBar = { + // Fade the status-bar strip plus HALF the app-bar row (behind the chrome): the hero card + // dims as it scrolls up under the bar (matching iOS) while staying vibrant below the bar's + // midline. [appBarHeight] is measured on the app bar alone (status bar excluded), so the + // scrim = status bar + half the app bar. The scrim never grows the content inset (the + // scaffold measures the full bar), so there's no jump. + var appBarHeight by remember { mutableStateOf(0.dp) } + val statusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + Box { + Box( + modifier = Modifier + .fillMaxWidth() + .height(statusBarHeight + appBarHeight * 0.5f) + .background( + Brush.verticalGradient( + 0f to CodeTheme.colors.background, + 1f to Color.Transparent, ) - ) - Box(modifier = Modifier.statusBarsPadding()) { - Box(modifier = Modifier.measured { appBarHeight = it.height }) { - appBar(hazeState) - } + ) + ) + Box(modifier = Modifier.statusBarsPadding()) { + Box(modifier = Modifier.measured { appBarHeight = it.height }) { + appBar(hazeState) } } - }, - ) { topPadding -> - TokenInfoScreen( - viewModel = viewModel, - shortfall = shortFall, - listState = listState, - contentPadding = PaddingValues( - top = topPadding, - bottom = bottomInset + CodeTheme.dimens.grid.x8, - ), - hazeState = hazeState, - ) - } - } else { - Column( - modifier = Modifier.fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - appBar(null) - TokenInfoScreen(viewModel, shortFall, listState) - } + } + }, + ) { topPadding -> + TokenInfoScreen( + viewModel = viewModel, + shortfall = shortFall, + listState = listState, + contentPadding = PaddingValues( + top = topPadding, + bottom = bottomInset + CodeTheme.dimens.grid.x8, + ), + hazeState = hazeState, + ) } LaunchedEffect(Unit) { 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 627b0eef49..5eb6ba947a 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 @@ -1,63 +1,14 @@ package com.flipcash.app.tokens.internal -import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.RowScope -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.aspectRatio -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.navigationBarsPadding -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material.Divider -import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.StrokeCap -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.flipcash.app.analytics.Button -import com.flipcash.app.analytics.FlipcashAnalyticsService -import com.flipcash.app.analytics.rememberAnalytics -import com.flipcash.app.core.AppRoute -import com.flipcash.app.core.data.Loadable -import com.flipcash.app.core.tokens.SwapPurpose -import com.flipcash.app.featureflags.FeatureFlag -import com.flipcash.app.featureflags.LocalFeatureFlags import com.flipcash.app.tokens.internal.components.info.CurrencyInfoContentV2 -import com.flipcash.app.tokens.internal.components.info.MarketCapSection -import com.flipcash.app.tokens.internal.components.info.TokenBalance -import com.flipcash.app.tokens.internal.components.info.TokenDetailsSection import com.flipcash.app.tokens.ui.TokenInfoViewModel -import com.flipcash.features.tokens.R import com.getcode.opencode.model.financial.Fiat -import com.getcode.solana.keys.Mint -import com.getcode.theme.CodeTheme -import com.getcode.ui.core.drawWithGradient -import com.getcode.ui.core.measured -import com.getcode.ui.core.verticalScrollStateGradient -import com.getcode.ui.theme.ButtonState -import com.getcode.ui.theme.CodeButton -import com.getcode.ui.theme.CodeCircularProgressIndicator -import com.getcode.ui.theme.CodeScaffold -import com.getcode.ui.utils.calculateEndPadding -import com.getcode.ui.utils.calculateStartPadding -import com.getcode.ui.utils.sheetResignmentBehavior import dev.chrisbanes.haze.HazeState @Composable @@ -69,360 +20,15 @@ internal fun TokenInfoScreen( hazeState: HazeState? = null, ) { val state by viewModel.stateFlow.collectAsStateWithLifecycle() - TokenInfoScreen(shortfall, state, listState, contentPadding, hazeState, viewModel::dispatchEvent) -} - -@Composable -private fun TokenInfoScreen( - shortfall: Fiat?, - state: TokenInfoViewModel.State, - listState: LazyListState, - contentPadding: PaddingValues, - hazeState: HazeState?, - dispatch: (TokenInfoViewModel.Event) -> Unit -) { - val features = LocalFeatureFlags.current - // Collect rather than snapshot `.value` — the flow is seeded with the flag's default until - // DataStore emits, so a remembered read freezes the default (see MenuScreenContent). - val isNewUi by features.observe(FeatureFlag.NewUi).collectAsStateWithLifecycle() - - if (isNewUi) { - // v2 hosts its own overlaid app bar (see the outer TokenInfoScreen); content fills behind it, - // marked as the haze source so the frosted bar chrome frosts it, and inset by [contentPadding]. - CurrencyInfoContentV2( - shortfall = shortfall, - state = state, - listState = listState, - contentPadding = contentPadding, - hazeState = hazeState, - dispatch = dispatch, - ) - return - } - - CodeScaffold( - bottomBar = { BottomBar(shortfall, state, dispatch) } - ) { innerPadding -> - Box( - modifier = Modifier.verticalScrollStateGradient( - listState, - color = CodeTheme.colors.background, - isLongGradient = true, - showAtEnd = false, - ) - ) { - LazyColumn( - modifier = Modifier - .fillMaxSize() - .padding( - start = innerPadding.calculateStartPadding(), - end = innerPadding.calculateEndPadding(), - ) - .sheetResignmentBehavior(listState), - state = listState, - ) { - when (val loadable = state.token) { - is Loadable.Loading -> { - item { - Box(modifier = Modifier.fillParentMaxSize()) { - Box( - modifier = Modifier - .fillParentMaxSize(0.24f) - .aspectRatio(1f) - .align(Alignment.Center), - ) { - CodeCircularProgressIndicator( - modifier = Modifier - .matchParentSize(), - strokeWidth = CodeTheme.dimens.grid.x1, - color = Color.White, - backgroundColor = Color.White.copy(0.30f), - strokeCap = StrokeCap.Butt, - ) - } - } - } - } - - is Loadable.Error -> { - item { - Box(modifier = Modifier.fillParentMaxSize()) { - Box( - modifier = Modifier - .fillParentMaxSize(0.24f) - .aspectRatio(1f) - .align(Alignment.Center), - ) { - Image( - modifier = Modifier - .matchParentSize(), - painter = painterResource(R.drawable.ic_circle_exclamation_large), - contentDescription = null, - ) - } - } - } - } - - is Loadable.Loaded -> { - item { - TokenBalance( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = CodeTheme.dimens.inset), - balance = state.balance.nativeAmount, - appreciation = state.appreciation?.nativeAmount?.takeIf { state.showAppreciation }, - onClick = { - dispatch( - TokenInfoViewModel.Event.OpenScreen( - AppRoute.Main.RegionSelection - ) - ) - } - ) - } - - if (!state.isCashReserve && state.showTransactionHistory) { - item { - CodeButton( - modifier = Modifier - .fillParentMaxWidth() - .padding(horizontal = CodeTheme.dimens.inset) - .padding(top = CodeTheme.dimens.grid.x5), - buttonState = ButtonState.Filled10, - text = stringResource(R.string.action_viewTransactionHistory), - ) { - dispatch( - TokenInfoViewModel.Event.OpenScreen( - AppRoute.Token.Transactions(loadable.data.address) - ) - ) - } - - Divider( - modifier = Modifier.padding( - horizontal = CodeTheme.dimens.inset, - vertical = CodeTheme.dimens.grid.x5 - ), - color = CodeTheme.colors.dividerVariant, - ) - } - } - - // currency info - item { - if (state.isCashReserve) { - Text( - modifier = Modifier - .fillParentMaxWidth() - .padding(horizontal = CodeTheme.dimens.inset), - text = loadable.data.description, - style = CodeTheme.typography.textMedium, - color = CodeTheme.colors.textSecondary, - ) - } else { - TokenDetailsSection( - modifier = Modifier - .fillParentMaxWidth(), - state = state, - dispatch = dispatch - ) - } - } - - if (!state.isCashReserve) { - // market cap - state.marketCap?.let { mcap -> - val loadable = state.historicalMarketCapData[state.selectedPeriod] - ?: Loadable.Loaded(emptyList()) - item { - MarketCapSection( - modifier = Modifier - .fillParentMaxWidth(), - contentPadding = PaddingValues(horizontal = CodeTheme.dimens.inset), - marketCap = mcap, - selectedPeriod = state.selectedPeriod, - rawHistoricalData = loadable, - onRetry = { - dispatch( - TokenInfoViewModel.Event.LoadHistoricalDataForPeriod( - state.selectedPeriod - ) - ) - }, - onPeriodSelected = { - dispatch( - TokenInfoViewModel.Event.OnMarketCapPeriodSelected( - it - ) - ) - }, - ) - } - } - } - - item { Spacer(Modifier.height(innerPadding.calculateBottomPadding())) } - } - } - - } - } - } -} - -@Composable -private fun BottomBar( - shortfall: Fiat?, - state: TokenInfoViewModel.State, - dispatch: (TokenInfoViewModel.Event) -> Unit -) { - var buttonHeight by remember { mutableStateOf(0.dp) } - Box { - Box( - modifier = Modifier - .fillMaxWidth() - .height(buttonHeight) - .drawWithGradient( - color = CodeTheme.colors.background, - startY = { 0f }, - endY = { size.height * 0.38f } - ) - ) - BottomBarButtons( - modifier = Modifier - .measured { buttonHeight = it.height } - .navigationBarsPadding() - .padding(horizontal = CodeTheme.dimens.inset) - .padding( - top = CodeTheme.dimens.grid.x9, - bottom = CodeTheme.dimens.grid.x3 - ), - shortfall = shortfall, - state = state, - dispatch = dispatch - ) - } + // The overlaid app bar is hosted by the outer TokenInfoScreen; content fills behind it, marked as + // the haze source so the frosted bar chrome frosts it, and inset by [contentPadding]. + CurrencyInfoContentV2( + shortfall = shortfall, + state = state, + listState = listState, + contentPadding = contentPadding, + hazeState = hazeState, + dispatch = viewModel::dispatchEvent, + ) } - -@Composable -private fun BottomBarButtons( - shortfall: Fiat?, - state: TokenInfoViewModel.State, - modifier: Modifier = Modifier, - dispatch: (TokenInfoViewModel.Event) -> Unit -) { - val analytics = rememberAnalytics() - when (val loadable = state.token) { - is Loadable.Error -> Unit - is Loadable.Loaded -> { - Row( - modifier = modifier - .fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), - ) { - if (state.isCashReserve) { - ReserveButtonOptions( - state = state, - dispatch = dispatch, - ) - } else { - ButtonOptions( - analytics = analytics, - mint = loadable.data.address, - shortfall = shortfall, - state = state, - dispatch = dispatch, - ) - } - } - } - - is Loadable.Loading -> Unit - } -} - -@Composable -private fun RowScope.ReserveButtonOptions( - state: TokenInfoViewModel.State, - dispatch: (TokenInfoViewModel.Event) -> Unit -) { - val hasBalance = state.balance.nativeAmount.isPositive - - if (hasBalance) { - // USDF/Dollars is only giveable in the new UI (v2 currency-info tiles); the legacy reserve - // layout offers Withdraw + Deposit only. - CodeButton( - modifier = Modifier.weight(1f), - buttonState = ButtonState.Filled20, - text = stringResource(R.string.action_withdraw), - ) { - dispatch( - TokenInfoViewModel.Event.OpenScreen( - AppRoute.Transfers.Withdrawal(showOtherOptions = false) - ) - ) - } - } - - CodeButton( - modifier = Modifier.weight(1f), - buttonState = ButtonState.Filled20, - text = stringResource(R.string.action_deposit), - ) { - dispatch(TokenInfoViewModel.Event.PresentDepositOptions) - } -} - -@Composable -private fun RowScope.ButtonOptions( - analytics: FlipcashAnalyticsService, - mint: Mint, - shortfall: Fiat?, - state: TokenInfoViewModel.State, - dispatch: (TokenInfoViewModel.Event) -> Unit -) { - val canGive = state.balance.nativeAmount.isPositive - - CodeButton( - modifier = Modifier.weight(1f), - buttonState = ButtonState.Filled, - text = stringResource(R.string.action_buy), - ) { - dispatch(TokenInfoViewModel.Event.OnBuy(shortfall)) - } - - if (canGive) { - CodeButton( - modifier = Modifier.weight(1f), - buttonState = ButtonState.Filled20, - text = stringResource(R.string.action_give), - ) { - dispatch( - TokenInfoViewModel.Event.OpenScreen( - AppRoute.Sheets.Give(mint = mint, fromTokenInfo = true) - ) - ) - } - } - - if (state.canSell) { - CodeButton( - modifier = Modifier - .weight(1f), - buttonState = ButtonState.Filled20, - text = stringResource(R.string.action_sell), - ) { - analytics.buttonTapped(Button.TokenSell) - dispatch( - TokenInfoViewModel.Event.OpenScreen( - AppRoute.Token.Swap( - purpose = SwapPurpose.Sell(mint), - ) - ) - ) - } - } -} \ No newline at end of file diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/components/info/CurrencyInfoContentV2.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/components/info/CurrencyInfoContentV2.kt index 0fe6ee70ac..5e85ad642c 100644 --- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/components/info/CurrencyInfoContentV2.kt +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/components/info/CurrencyInfoContentV2.kt @@ -404,10 +404,7 @@ private fun CurrencyActionTiles( // "Withdraw as USDC" intro, anything else opens straight on the amount screen. dispatch( TokenInfoViewModel.Event.OpenScreen( - AppRoute.Transfers.Withdrawal( - showOtherOptions = false, - preselectedMint = tokenMint, - ) + AppRoute.Transfers.Withdrawal(preselectedMint = tokenMint) ) ) }, diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/components/info/TokenBalance.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/components/info/TokenBalance.kt deleted file mode 100644 index af0ba7102d..0000000000 --- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/components/info/TokenBalance.kt +++ /dev/null @@ -1,58 +0,0 @@ -package com.flipcash.app.tokens.internal.components.info - -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.runtime.Composable -import androidx.compose.runtime.key -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import com.flipcash.app.core.ui.CurrencyAppreciationLabel -import com.getcode.opencode.compose.LocalExchange -import com.getcode.opencode.model.financial.Fiat -import com.getcode.theme.CodeTheme -import com.getcode.ui.components.text.AmountArea -import com.getcode.ui.theme.CodeCircularProgressIndicator - -@Composable -internal fun TokenBalance( - modifier: Modifier = Modifier, - balance: Fiat?, - appreciation: Fiat?, - onClick: () -> Unit -) { - val exchange = LocalExchange.current - Column( - modifier = modifier - .padding(horizontal = CodeTheme.dimens.inset) - .padding(vertical = CodeTheme.dimens.grid.x9), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - if (balance == null) { - Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { - CodeCircularProgressIndicator() - } - } else { - key(balance) { - AmountArea( - amountText = balance.formatted(), - isAltCaption = false, - isAltCaptionKinIcon = false, - captionText = null, - currencyResId = exchange.getFlagByCurrency(balance.currencyCode.name), - isClickable = true, - animateDigits = true, - textStyle = CodeTheme.typography.displayLarge, - onClick = onClick - ) - } - - if (appreciation != null) { - key(appreciation) { - CurrencyAppreciationLabel(appreciation) - } - } - } - } -} \ No newline at end of file diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/components/info/TokenDetails.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/components/info/TokenDetails.kt deleted file mode 100644 index 6b3fd557e7..0000000000 --- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/components/info/TokenDetails.kt +++ /dev/null @@ -1,129 +0,0 @@ -package com.flipcash.app.tokens.internal.components.info - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -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.lazy.LazyRow -import androidx.compose.foundation.lazy.items -import androidx.compose.material.Divider -import androidx.compose.material.Icon -import androidx.compose.material.Text -import androidx.compose.runtime.Composable -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.tooling.preview.datasource.LoremIpsum -import androidx.compose.ui.unit.dp -import com.flipcash.app.theme.FlipcashPreview -import com.flipcash.app.tokens.ui.TokenInfoViewModel -import com.flipcash.features.tokens.R -import com.getcode.theme.CodeTheme -import com.getcode.ui.components.text.ExpandableText -import com.getcode.util.format - - -@Composable -internal fun TokenDetailsSection( - modifier: Modifier = Modifier, - state: TokenInfoViewModel.State, - dispatch: (TokenInfoViewModel.Event) -> Unit -) { - Column( - modifier = modifier, - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = CodeTheme.dimens.inset), - horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1), - ) { - Icon( - modifier = Modifier.size(CodeTheme.dimens.staticGrid.x4), - painter = painterResource(R.drawable.ic_info_bars), - contentDescription = null, - ) - - Text( - modifier = Modifier.weight(1f), - text = stringResource(R.string.subtitle_currencyInfo), - style = CodeTheme.typography.textMedium, - color = CodeTheme.colors.textMain, - ) - } - - state.token.dataOrNull?.createdAt?.let { mintDate -> - Text( - modifier = Modifier - .padding(horizontal = CodeTheme.dimens.inset) - .padding(top = CodeTheme.dimens.grid.x1), - text = stringResource( - R.string.label_mintDate, - mintDate.format("MMMM dd, yyyy") - ), - style = CodeTheme.typography.textMedium, - color = CodeTheme.colors.textSecondary, - ) - } - ExpandableText( - modifier = Modifier - .padding( - top = if (state.token.dataOrNull?.createdAt == null) { - CodeTheme.dimens.grid.x1 - } else { - CodeTheme.dimens.grid.x2 - } - ), - text = state.token.dataOrNull?.description.orEmpty(), - style = CodeTheme.typography.textMedium, - color = CodeTheme.colors.textSecondary, - isExpanded = state.descriptionExpanded, - contentPadding = PaddingValues(horizontal = CodeTheme.dimens.inset) - ) { - dispatch(TokenInfoViewModel.Event.ExpandDescription(!state.descriptionExpanded)) - } - - state.token.dataOrNull?.socialLinks?.let { links -> - LazyRow( - modifier = Modifier - .fillMaxWidth() - .padding(top = CodeTheme.dimens.grid.x4), - horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), - contentPadding = PaddingValues(horizontal = CodeTheme.dimens.inset) - ) { - items(links, key = { it.uri }) { link -> - SocialChip(link) - } - } - } - - Divider( - modifier = Modifier.padding( - horizontal = CodeTheme.dimens.inset, - vertical = CodeTheme.dimens.grid.x5 - ), - color = CodeTheme.colors.dividerVariant, - ) - } -} - -@Composable -@Preview -private fun PreviewTokenDetails() { - FlipcashPreview(showBackground = true) { - ExpandableText( - modifier = Modifier.padding(horizontal = CodeTheme.dimens.inset), - text = LoremIpsum(words = 400).values.joinToString(" "), - contentPadding = PaddingValues(horizontal = 16.dp), - style = CodeTheme.typography.textMedium, - color = CodeTheme.colors.textSecondary, - isExpanded = false, - isExpandable = false, - onToggle = { } - ) - } -} \ No newline at end of file diff --git a/apps/flipcash/features/withdrawal/src/main/kotlin/com/flipcash/app/withdrawal/WithdrawalFlowScreen.kt b/apps/flipcash/features/withdrawal/src/main/kotlin/com/flipcash/app/withdrawal/WithdrawalFlowScreen.kt index d757e0565b..3db5373635 100644 --- a/apps/flipcash/features/withdrawal/src/main/kotlin/com/flipcash/app/withdrawal/WithdrawalFlowScreen.kt +++ b/apps/flipcash/features/withdrawal/src/main/kotlin/com/flipcash/app/withdrawal/WithdrawalFlowScreen.kt @@ -20,8 +20,6 @@ import com.flipcash.app.core.AppRoute import com.flipcash.app.core.tokens.TokenPurpose import com.flipcash.app.core.withdrawal.WithdrawalResult import com.flipcash.app.core.withdrawal.WithdrawalStep -import com.flipcash.app.featureflags.FeatureFlag -import com.flipcash.app.featureflags.LocalFeatureFlags import com.flipcash.app.theme.FlipcashThemeWrapper import com.flipcash.app.tokens.ui.SelectTokenViewModel import com.flipcash.app.tokens.ui.TokenList @@ -80,15 +78,13 @@ fun WithdrawalFlowScreen( } } }, - entryProvider = withdrawalEntryProvider(route.showOtherOptions), + entryProvider = withdrawalEntryProvider(), ) } -private fun withdrawalEntryProvider( - showOtherOptions: Boolean, -): (NavKey) -> NavEntry = entryProvider { +private fun withdrawalEntryProvider(): (NavKey) -> NavEntry = entryProvider { annotatedEntry { - UsdcWithdrawalInformationScreen(showOtherOptions) + UsdcWithdrawalInformationScreen() } annotatedEntry { WithdrawalSelectTokenScreen() @@ -111,8 +107,6 @@ private fun WithdrawalSelectTokenScreen() { val flowNavigator = rememberFlowNavigator() val viewModel = hiltViewModel() val state by viewModel.stateFlow.collectAsStateWithLifecycle() - val isNewUi by LocalFeatureFlags.current.observe(FeatureFlag.NewUi) - .collectAsStateWithLifecycle() Column( modifier = Modifier.fillMaxSize(), @@ -135,18 +129,17 @@ private fun WithdrawalSelectTokenScreen() { viewModel.dispatchEvent(SelectTokenViewModel.Event.OnPurposeChanged(TokenPurpose.Withdraw)) } - LaunchedEffect(viewModel, isNewUi) { + LaunchedEffect(viewModel) { viewModel.eventFlow .filterIsInstance() .filter { it.fromUser } .map { it.mint } .onEach { mint -> - // v2 makes the picker the flow's entry, so Dollars has to detour through the - // "Withdraw as USDC" intro here — v1 reaches that intro first and only lands on the - // picker via its escape hatch, so a pick there always goes straight to the amount. + // The picker is the flow's entry, so Dollars has to detour through the "Withdraw as + // USDC" intro here; every other currency goes straight to the amount. val isReserve = mint == Mint.usdf || mint == Mint.usdc - if (isNewUi && isReserve) { - flowNavigator.navigateTo(WithdrawalStep.UsdcInformational(showOtherOptions = false)) + if (isReserve) { + flowNavigator.navigateTo(WithdrawalStep.UsdcInformational) } else { flowNavigator.navigateTo(WithdrawalStep.Amount(mint)) } @@ -170,6 +163,6 @@ private fun WithdrawalFlowPreview( @PreviewWrapper(FlipcashThemeWrapper::class) @Composable private fun Preview_UsdcInformational() { - WithdrawalFlowPreview { UsdcWithdrawalInformationScreen(showOtherOptions = true) } + WithdrawalFlowPreview { UsdcWithdrawalInformationScreen() } } diff --git a/apps/flipcash/features/withdrawal/src/main/kotlin/com/flipcash/app/withdrawal/internal/screens/UsdcWithdrawalInformationScreen.kt b/apps/flipcash/features/withdrawal/src/main/kotlin/com/flipcash/app/withdrawal/internal/screens/UsdcWithdrawalInformationScreen.kt index 4d1dd4b1a6..758ccd8e32 100644 --- a/apps/flipcash/features/withdrawal/src/main/kotlin/com/flipcash/app/withdrawal/internal/screens/UsdcWithdrawalInformationScreen.kt +++ b/apps/flipcash/features/withdrawal/src/main/kotlin/com/flipcash/app/withdrawal/internal/screens/UsdcWithdrawalInformationScreen.kt @@ -12,18 +12,14 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flipcash.app.core.withdrawal.WithdrawalResult import com.flipcash.app.core.withdrawal.WithdrawalStep -import com.flipcash.app.featureflags.FeatureFlag -import com.flipcash.app.featureflags.LocalFeatureFlags import com.flipcash.core.R import com.getcode.navigation.flow.rememberFlowNavigator import com.getcode.solana.keys.Mint @@ -34,17 +30,15 @@ import com.getcode.ui.theme.CodeButton import com.getcode.ui.theme.CodeScaffold @Composable -internal fun UsdcWithdrawalInformationScreen(showOtherOptions: Boolean) { +internal fun UsdcWithdrawalInformationScreen() { val flowNavigator = rememberFlowNavigator() - val isNewUi by LocalFeatureFlags.current.observe(FeatureFlag.NewUi) - .collectAsStateWithLifecycle() CodeScaffold( topBar = { AppBarWithTitle( - // v2 reaches this screen mid-flow (picker → Dollars), where the screen's own heading - // already names it; v1 enters here, so it keeps the "Withdraw" title. - title = if (isNewUi) "" else stringResource(R.string.title_withdraw), + // Reached mid-flow (picker → Dollars), where the screen's own heading already names + // it — so the bar carries no title of its own. + title = "", onBackIconClicked = { flowNavigator.back() }, titleAlignment = Alignment.CenterHorizontally, ) @@ -64,19 +58,6 @@ internal fun UsdcWithdrawalInformationScreen(showOtherOptions: Boolean) { ) { flowNavigator.navigateTo(WithdrawalStep.Amount(Mint.usdc)) } - - // The escape hatch is a v1 affordance: v2 enters the flow on the picker, so every - // other currency is already one step back. - if (showOtherOptions && !isNewUi) { - CodeButton( - modifier = Modifier - .fillMaxWidth(), - buttonState = ButtonState.Subtle, - text = stringResource(R.string.action_withdrawOtherCurrencies), - ) { - flowNavigator.navigateTo(WithdrawalStep.SelectToken) - } - } } } ) { padding -> @@ -95,18 +76,11 @@ internal fun UsdcWithdrawalInformationScreen(showOtherOptions: Boolean) { modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center, ) { - if (isNewUi) { - ConversionGraphic() - } else { - Image( - painter = painterResource(R.drawable.ic_withdraw_usdf_as_usdc), - contentDescription = null, - ) - } + ConversionGraphic() } Column( - modifier = Modifier.fillMaxWidth(if (isNewUi) 0.80f else 0.60f), + modifier = Modifier.fillMaxWidth(0.80f), verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3), horizontalAlignment = Alignment.CenterHorizontally, ) { @@ -116,13 +90,7 @@ internal fun UsdcWithdrawalInformationScreen(showOtherOptions: Boolean) { color = CodeTheme.colors.textMain, ) Text( - text = stringResource( - if (isNewUi) { - R.string.description_withdrawDollarsAsUsdc - } else { - R.string.description_withdrawUsdfAsUsdc - } - ), + text = stringResource(R.string.description_withdrawDollarsAsUsdc), style = CodeTheme.typography.textSmall, color = CodeTheme.colors.textSecondary, textAlign = TextAlign.Center, @@ -134,9 +102,8 @@ internal fun UsdcWithdrawalInformationScreen(showOtherOptions: Boolean) { } /** - * v2 "Dollars → USDC" graphic (Figma node 9216:19798). The v1 art carried the Flipcash "F" mark on - * the left; v2 swaps in the gold Dollars coin, so the two halves are composed here rather than - * shipped as one flattened asset. + * The "Dollars → USDC" graphic (Figma node 9216:19798). The gold Dollars coin and the USDC-on-Solana + * mark are composed here rather than shipped as one flattened asset. */ @Composable private fun ConversionGraphic() { diff --git a/apps/flipcash/features/withdrawal/src/main/kotlin/com/flipcash/app/withdrawal/internal/screens/WithdrawalDestinationScreen.kt b/apps/flipcash/features/withdrawal/src/main/kotlin/com/flipcash/app/withdrawal/internal/screens/WithdrawalDestinationScreen.kt index a426673a4d..032a550ad6 100644 --- a/apps/flipcash/features/withdrawal/src/main/kotlin/com/flipcash/app/withdrawal/internal/screens/WithdrawalDestinationScreen.kt +++ b/apps/flipcash/features/withdrawal/src/main/kotlin/com/flipcash/app/withdrawal/internal/screens/WithdrawalDestinationScreen.kt @@ -4,15 +4,11 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flipcash.app.core.withdrawal.WithdrawalResult import com.flipcash.app.core.withdrawal.WithdrawalStep -import com.flipcash.app.featureflags.FeatureFlag -import com.flipcash.app.featureflags.LocalFeatureFlags import com.flipcash.app.withdrawal.WithdrawalViewModel import com.flipcash.app.withdrawal.internal.destination.WithdrawalDestinationScreen import com.flipcash.core.R @@ -27,17 +23,12 @@ import kotlinx.coroutines.flow.onEach internal fun WithdrawalDestinationScreen() { val flowNavigator = rememberFlowNavigator() val viewModel = flowSharedViewModel() - val isNewUi by LocalFeatureFlags.current.observe(FeatureFlag.NewUi) - .collectAsStateWithLifecycle() - Column( modifier = Modifier.fillMaxSize(), ) { AppBarWithTitle( - // v2 names each step; v1 keeps the flow-wide "Withdraw" title on every screen. - title = stringResource( - if (isNewUi) R.string.title_addressEntry else R.string.title_withdraw - ), + // Each step of the flow names itself. + title = stringResource(R.string.title_addressEntry), titleAlignment = Alignment.CenterHorizontally, onBackIconClicked = { flowNavigator.back() }, ) diff --git a/apps/flipcash/features/withdrawal/src/main/kotlin/com/flipcash/app/withdrawal/internal/screens/WithdrawalEntryScreen.kt b/apps/flipcash/features/withdrawal/src/main/kotlin/com/flipcash/app/withdrawal/internal/screens/WithdrawalEntryScreen.kt index 7e003587ae..05e966419a 100644 --- a/apps/flipcash/features/withdrawal/src/main/kotlin/com/flipcash/app/withdrawal/internal/screens/WithdrawalEntryScreen.kt +++ b/apps/flipcash/features/withdrawal/src/main/kotlin/com/flipcash/app/withdrawal/internal/screens/WithdrawalEntryScreen.kt @@ -4,16 +4,12 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flipcash.app.core.AppRoute import com.flipcash.app.core.withdrawal.WithdrawalResult import com.flipcash.app.core.withdrawal.WithdrawalStep -import com.flipcash.app.featureflags.FeatureFlag -import com.flipcash.app.featureflags.LocalFeatureFlags import com.flipcash.app.withdrawal.WithdrawalViewModel import com.flipcash.app.withdrawal.internal.entry.WithdrawalEntryScreen import com.flipcash.core.R @@ -33,17 +29,12 @@ internal fun WithdrawalEntryScreen( val codeNavigator = LocalCodeNavigator.current val flowNavigator = rememberFlowNavigator() val viewModel = flowSharedViewModel() - val isNewUi by LocalFeatureFlags.current.observe(FeatureFlag.NewUi) - .collectAsStateWithLifecycle() - Column( modifier = Modifier.fillMaxSize(), ) { AppBarWithTitle( - // v2 names each step; v1 keeps the flow-wide "Withdraw" title on every screen. - title = stringResource( - if (isNewUi) R.string.title_amountToWithdraw else R.string.title_withdraw - ), + // Each step of the flow names itself. + title = stringResource(R.string.title_amountToWithdraw), onBackIconClicked = { flowNavigator.back() }, titleAlignment = Alignment.CenterHorizontally, ) diff --git a/apps/flipcash/shared/activityfeed/src/main/kotlin/com/flipcash/app/activityfeed/ActivityFeedCoordinator.kt b/apps/flipcash/shared/activityfeed/src/main/kotlin/com/flipcash/app/activityfeed/ActivityFeedCoordinator.kt index d5b95e87f8..8b65a767f3 100644 --- a/apps/flipcash/shared/activityfeed/src/main/kotlin/com/flipcash/app/activityfeed/ActivityFeedCoordinator.kt +++ b/apps/flipcash/shared/activityfeed/src/main/kotlin/com/flipcash/app/activityfeed/ActivityFeedCoordinator.kt @@ -1,7 +1,7 @@ package com.flipcash.app.activityfeed @Deprecated( - "Moved to :shared:transaction-history as part of the v2 (FeatureFlag.NewUi) phase-out. " + + "Moved to :shared:transaction-history as part of the v2 UI phase-out. " + "Import com.flipcash.shared.transactionhistory.ActivityFeedCoordinator instead. " + "Plan: docs/superpowers/plans/2026-08-11-activityfeed-phaseout.md", ReplaceWith( diff --git a/apps/flipcash/shared/activityfeed/src/main/kotlin/com/flipcash/app/activityfeed/ActivityFeedUpdater.kt b/apps/flipcash/shared/activityfeed/src/main/kotlin/com/flipcash/app/activityfeed/ActivityFeedUpdater.kt index 6d90363ff3..970a0f5a7a 100644 --- a/apps/flipcash/shared/activityfeed/src/main/kotlin/com/flipcash/app/activityfeed/ActivityFeedUpdater.kt +++ b/apps/flipcash/shared/activityfeed/src/main/kotlin/com/flipcash/app/activityfeed/ActivityFeedUpdater.kt @@ -1,7 +1,7 @@ package com.flipcash.app.activityfeed @Deprecated( - "Moved to :shared:transaction-history as part of the v2 (FeatureFlag.NewUi) phase-out. " + + "Moved to :shared:transaction-history as part of the v2 UI phase-out. " + "Import com.flipcash.shared.transactionhistory.ActivityFeedUpdater instead. " + "Plan: docs/superpowers/plans/2026-08-11-activityfeed-phaseout.md", ReplaceWith( diff --git a/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/decor/TipCardDecorator.kt b/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/decor/TipCardDecorator.kt index 3cd50b2f97..177c634733 100644 --- a/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/decor/TipCardDecorator.kt +++ b/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/decor/TipCardDecorator.kt @@ -20,8 +20,6 @@ import com.flipcash.app.core.extensions.openAsSheet import com.flipcash.app.core.tipping.LocalTipCoordinator import com.flipcash.app.core.tipping.TipEvent import com.flipcash.app.bills.modals.TipUserModal -import com.flipcash.app.featureflags.FeatureFlag -import com.flipcash.app.featureflags.LocalFeatureFlags import com.flipcash.app.session.LocalSessionController import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.ui.core.measured @@ -52,7 +50,6 @@ internal data class TipCardDecorator(private val tipCard: Scannable.TipCard) : S val navigator = LocalCodeNavigator.current val tipCoordinator = LocalTipCoordinator.current val selection by tipCoordinator.selection.collectAsState() - val isNewUi by LocalFeatureFlags.current.observe(FeatureFlag.NewUi).collectAsState() val tipPresented = context.liveBill is Scannable.TipCard // Can't afford the minimum tip → the modal stays hidden (gated by @@ -80,15 +77,13 @@ internal data class TipCardDecorator(private val tipCard: Scannable.TipCard) : S navigator.openAsSheet(event.route) } // Open the completed tip's chat with the tips list beneath it, so back returns - // to the list. In v1 navigateAll packs the chat into the tips sheet's back - // stack; in v2 it switches to the Chats tab and pushes the chat onto it. + // to the list: navigateAll switches to the Chats tab and pushes the chat onto it. is TipEvent.LaunchChat -> { navigator.navigateAll( listOf( AppRoute.Sheets.Tips(), AppRoute.Messaging.Chat(event.identifier, openKeyboard = true), ), - isNewUi = isNewUi, ) context.onDismiss() } 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 89872b8c8b..08703a32dc 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 @@ -2,7 +2,6 @@ package com.flipcash.app.featureflags 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 enum class FeatureTrack { @@ -100,16 +99,6 @@ sealed interface FeatureFlag { override val persistLogOut: Boolean = true } - @FeatureFlagMarker - data object NavBar : FeatureFlag { - override val key: String = "nav_bar_config" - override val default: NavBarConfig = NavBarConfig.Default - override val launched: Boolean = false - override val visible: Boolean = false - override val persistLogOut: Boolean = false - override val defaultOption: String get() = default.serialize() - } - @FeatureFlagMarker data object ShowNetworkState: FeatureFlag { override val key: String = "show_network_state_enabled" @@ -128,20 +117,6 @@ sealed interface FeatureFlag { override val persistLogOut: Boolean = false } - @FeatureFlagMarker - data object NewUi: FeatureFlag { - override val key: String = "new_ui_enabled" - override val default: Boolean = true - // Launched: the new UI is now the only shell. `launched` makes the controller short-circuit - // to `default` — so a user who had toggled this OFF during the beta is moved onto it (their - // stored `false` is ignored and cleared on next launch) — and drops it from - // `availableEntries`, removing the toggle from Labs. The v1 code it gated is torn out - // separately. - override val launched: Boolean = true - override val visible: Boolean = true - override val persistLogOut: Boolean = true - } - companion object { val entries: List> get() = FeatureFlagEntries.entries @@ -162,10 +137,8 @@ val FeatureFlag<*>.title: String FeatureFlag.BillTextures -> "Bill Textures" FeatureFlag.BackgroundReset -> "Background Reset" FeatureFlag.ContactPickerMode -> "Contact Picker Mode" - FeatureFlag.NavBar -> "Navigation Bar" FeatureFlag.ShowNetworkState -> "Network Offline Indicator" FeatureFlag.FrostedTipCard -> "Frosted Tip Card" - FeatureFlag.NewUi -> "New UI" } val FeatureFlag<*>.message: String @@ -177,10 +150,8 @@ val FeatureFlag<*>.message: String FeatureFlag.BillTextures -> "When enabled, you'll gain the ability to select textures for bills during currency creation" 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.NavBar -> "Customize the order and labels of navigation bar buttons" FeatureFlag.ShowNetworkState -> "When enabled, you'll gain the ability to see the network state on the Scanner when offline" FeatureFlag.FrostedTipCard -> "When enabled, the tip card in the scanner renders as frosted glass over a blurred snapshot of the camera instead of a solid card" - FeatureFlag.NewUi -> "When enabled, the app will use the tipping first UI" } diff --git a/apps/flipcash/shared/funding/src/main/kotlin/com/flipcash/app/funding/internal/Buttons.kt b/apps/flipcash/shared/funding/src/main/kotlin/com/flipcash/app/funding/internal/Buttons.kt index 8e2c733c0f..c4fcda7027 100644 --- a/apps/flipcash/shared/funding/src/main/kotlin/com/flipcash/app/funding/internal/Buttons.kt +++ b/apps/flipcash/shared/funding/src/main/kotlin/com/flipcash/app/funding/internal/Buttons.kt @@ -2,32 +2,17 @@ package com.flipcash.app.funding.internal import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.text.InlineTextContent -import androidx.compose.foundation.text.appendInlineContent import androidx.compose.material3.Icon 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.graphics.ColorFilter import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.Placeholder -import androidx.compose.ui.text.PlaceholderVerticalAlign -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import com.flipcash.app.core.money.formatted -import com.flipcash.app.funding.PaymentAction import com.flipcash.app.funding.PurchaseMethod import com.flipcash.app.funding.PurchaseMethodMetadata import com.flipcash.app.funding.PurchaseMethodState @@ -36,86 +21,13 @@ import com.getcode.manager.BottomBarAction import com.getcode.manager.BottomBarManager import com.getcode.opencode.model.financial.Fiat import com.getcode.theme.CodeTheme -import com.getcode.ui.theme.ButtonState import com.getcode.util.resources.ResourceHelper -internal fun purchaseOptions( - state: PurchaseMethodState, - metadata: PurchaseMethodMetadata, - resources: ResourceHelper, - isNewUi: Boolean, - onClick: (PurchaseMethod) -> Unit -): List { - if (isNewUi) return purchaseMethodCards(state, metadata, resources, onClick) - return buildList { - if (state.coinbaseOnRampAvailable) { - add( - buildButtonAction( - prefix = null, - suffix = null, - iconRes = when (metadata.paymentAction) { - PaymentAction.Buy -> R.drawable.ic_buy_with_google_pay - PaymentAction.Pay -> R.drawable.ic_pay_with_google_pay - PaymentAction.Plain -> R.drawable.ic_google_pay - }, - width = 150.sp, - height = 20.sp, - tintIcon = false, - testTag = "purchase_method_coinbase", - onClick = { onClick(PurchaseMethod.CoinbaseOnRamp) } - ) - ) - } - if (state.hasReserves && metadata.showReserves) { - val minimumAmountNeeded = metadata.purchaseAmount ?: Fiat.MIN_VALUE - if (state.reservesBalance.nativeAmount >= minimumAmountNeeded) { - add( - BottomBarAction( - text = resources.getString( - R.string.action_useCashReservesWithBalance, - state.reservesBalance.formatted() - ), - onClick = { onClick(PurchaseMethod.CashReserves(state.reservesBalance)) } - ) - ) - } - } - - add( - buildButtonAction( - prefix = null, - suffix = resources.getString(R.string.label_phantom), - iconPadding = { PaddingValues() }, - iconRes = R.drawable.ic_phantom_wallet, - testTag = "purchase_method_phantom", - onClick = { onClick(PurchaseMethod.PhantomWallet) } - ) - ) - - if (state.canUseOtherWallets) { - add( - BottomBarAction( - text = resources.getString(R.string.title_onrampProviderOtherWallet), - testTag = "purchase_method_other_wallet", - onClick = { onClick(PurchaseMethod.OtherWallet) } - ) - ) - } - - add( - BottomBarAction( - text = resources.getString(R.string.action_dismiss), - style = BottomBarManager.BottomBarButtonStyle.Text, - ) - ) - } -} - /** - * v2 "Add Money With" method rows — each a card (title + subtitle + trailing icon) rendered via the - * [BottomBarAction.content] slot. Same methods and conditions as the v1 buttons above. + * The "Add Money With" method rows — each a card (title + subtitle + trailing icon) rendered via the + * [BottomBarAction.content] slot. */ -private fun purchaseMethodCards( +internal fun purchaseOptions( state: PurchaseMethodState, metadata: PurchaseMethodMetadata, resources: ResourceHelper, @@ -221,61 +133,3 @@ private fun cardAction( } }, ) - -private fun buildButtonAction( - prefix: String?, - suffix: String?, - iconRes: Int, - width: TextUnit = 25.sp, - height: TextUnit = 14.sp, - iconPadding: @Composable () -> PaddingValues = { - PaddingValues( - start = CodeTheme.dimens.grid.x1 + 2.dp, - end = CodeTheme.dimens.grid.x1 - ) - }, - tintIcon: Boolean = true, - testTag: String? = null, - onClick: () -> Unit -): BottomBarAction { - return BottomBarAction( - testTag = testTag, - text = buildAnnotatedString { - if (prefix != null) { - append(prefix) - } - appendInlineContent("[icon]", alternateText = " ") - if (suffix != null) { - append(suffix) - } - }, - inlineContentMap = mapOf( - "[icon]" to InlineTextContent( - placeholder = Placeholder( - width = width, - height = height, - placeholderVerticalAlign = PlaceholderVerticalAlign.TextCenter - ), - children = { - val buttonColors = ButtonState.Filled.colors() - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - Image( - modifier = Modifier.padding(iconPadding()), - painter = painterResource(iconRes), - colorFilter = if (tintIcon) { - ColorFilter.tint(buttonColors.contentColor(true).value) - } else { - null - }, - contentDescription = null - ) - } - } - ) - ), - onClick = onClick - ) -} \ No newline at end of file diff --git a/apps/flipcash/shared/funding/src/main/kotlin/com/flipcash/app/funding/internal/InternalPurchaseMethodController.kt b/apps/flipcash/shared/funding/src/main/kotlin/com/flipcash/app/funding/internal/InternalPurchaseMethodController.kt index a0f79be472..be9ed7f5a7 100644 --- a/apps/flipcash/shared/funding/src/main/kotlin/com/flipcash/app/funding/internal/InternalPurchaseMethodController.kt +++ b/apps/flipcash/shared/funding/src/main/kotlin/com/flipcash/app/funding/internal/InternalPurchaseMethodController.kt @@ -5,8 +5,6 @@ import com.flipcash.app.analytics.FlipcashAnalyticsService import com.flipcash.app.core.AppRoute import com.flipcash.app.core.tokens.FundingSource import com.flipcash.app.core.tokens.SwapPurpose -import com.flipcash.app.featureflags.FeatureFlag -import com.flipcash.app.featureflags.FeatureFlagController import com.flipcash.app.funding.PaymentAction import com.flipcash.app.funding.PurchaseMethod import com.flipcash.app.funding.PurchaseMethodController @@ -56,7 +54,6 @@ class InternalPurchaseMethodController @Inject constructor( private val resources: ResourceHelper, private val userManager: UserManager, private val analytics: FlipcashAnalyticsService, - private val featureFlags: FeatureFlagController, ) : PurchaseMethodController { private val scope = CoroutineScope(SupervisorJob()) @@ -107,17 +104,9 @@ class InternalPurchaseMethodController @Inject constructor( var selected = false - val isNewUi = featureFlags.observe(FeatureFlag.NewUi).value - val title = if (isNewUi) { - resources.getString(R.string.prompt_title_addMoneyWith) - } else when (metadata.purpose) { - PurchasePurpose.Buy -> resources.getString(R.string.prompt_title_selectPurchaseMethod) - PurchasePurpose.Deposit -> resources.getString(R.string.prompt_title_selectMethod) - } - BottomBarManager.showMessage( - title = title, - actions = purchaseOptions(_state.value, metadata, resources, isNewUi) { method -> + title = resources.getString(R.string.prompt_title_addMoneyWith), + actions = purchaseOptions(_state.value, metadata, resources) { method -> selected = true scope.launch { val selection = PurchaseMethodSelection(method, metadata) diff --git a/apps/flipcash/shared/router/src/test/kotlin/com/flipcash/app/router/internal/NavigateToTest.kt b/apps/flipcash/shared/router/src/test/kotlin/com/flipcash/app/router/internal/NavigateToTest.kt index 179cbf16c6..e847c5c577 100644 --- a/apps/flipcash/shared/router/src/test/kotlin/com/flipcash/app/router/internal/NavigateToTest.kt +++ b/apps/flipcash/shared/router/src/test/kotlin/com/flipcash/app/router/internal/NavigateToTest.kt @@ -43,7 +43,7 @@ class NavigateToTest { val mint = Mint("So11111111111111111111111111111111111111112") navigator.navigateAll( - listOf(AppRoute.Sheets.Wallet, AppRoute.Token.Info(mint)), + listOf(AppRoute.Sheets.ActivityHistory, AppRoute.Token.Info(mint)), options = quietOptions, ) @@ -56,7 +56,7 @@ class NavigateToTest { fun `navigateTo non-sheet routes navigates directly even with existing sheet`() { val navigator = createNavigator( AppRoute.Main.Scanner, - AppRoute.Main.Sheet(AppRoute.Sheets.Wallet), + AppRoute.Main.Sheet(AppRoute.Sheets.ActivityHistory), ) navigator.navigateAll(listOf(AppRoute.Menu.MyAccount), options = quietOptions) @@ -72,12 +72,12 @@ class NavigateToTest { fun `navigateTo with existing sheet sets pendingSheetDismiss`() { val navigator = createNavigator( AppRoute.Main.Scanner, - AppRoute.Main.Sheet(AppRoute.Sheets.Wallet), + AppRoute.Main.Sheet(AppRoute.Sheets.ActivityHistory), ) val mint = Mint("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v") navigator.navigateAll( - listOf(AppRoute.Sheets.Wallet, AppRoute.Token.Info(mint)), + listOf(AppRoute.Sheets.ActivityHistory, AppRoute.Token.Info(mint)), options = quietOptions, ) @@ -90,11 +90,11 @@ class NavigateToTest { fun `pendingSheetDismiss callback increments sheetGeneration`() { val navigator = createNavigator( AppRoute.Main.Scanner, - AppRoute.Main.Sheet(AppRoute.Sheets.Wallet), + AppRoute.Main.Sheet(AppRoute.Sheets.ActivityHistory), ) val initialGeneration = navigator.sheetGeneration - navigator.navigateAll(listOf(AppRoute.Sheets.Wallet), options = quietOptions) + navigator.navigateAll(listOf(AppRoute.Sheets.ActivityHistory), options = quietOptions) // Simulate what ModalBottomSheetScene does: remove old sheet, then invoke callback navigator.backStack.removeAt(navigator.backStack.lastIndex) @@ -107,12 +107,12 @@ class NavigateToTest { fun `pendingSheetDismiss callback navigates to new routes`() { val navigator = createNavigator( AppRoute.Main.Scanner, - AppRoute.Main.Sheet(AppRoute.Sheets.Wallet), + AppRoute.Main.Sheet(AppRoute.Sheets.ActivityHistory), ) val mint = Mint("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v") navigator.navigateAll( - listOf(AppRoute.Sheets.Wallet, AppRoute.Token.Info(mint)), + listOf(AppRoute.Sheets.ActivityHistory, AppRoute.Token.Info(mint)), options = quietOptions, ) @@ -122,7 +122,7 @@ class NavigateToTest { val last = navigator.backStack.last() assertIs(last) - assertEquals(AppRoute.Sheets.Wallet, last.initialRoute) + assertEquals(AppRoute.Sheets.ActivityHistory, last.initialRoute) assertIs(last.innerRoutes.single()) } @@ -130,17 +130,17 @@ class NavigateToTest { fun `repeated dismiss-then-replace increments generation each time`() { val navigator = createNavigator( AppRoute.Main.Scanner, - AppRoute.Main.Sheet(AppRoute.Sheets.Wallet), + AppRoute.Main.Sheet(AppRoute.Sheets.ActivityHistory), ) // First replace - navigator.navigateAll(listOf(AppRoute.Sheets.Wallet), options = quietOptions) + navigator.navigateAll(listOf(AppRoute.Sheets.ActivityHistory), options = quietOptions) navigator.backStack.removeAt(navigator.backStack.lastIndex) navigator.pendingSheetDismiss!!.invoke() assertEquals(1, navigator.sheetGeneration) // Second replace - navigator.navigateAll(listOf(AppRoute.Sheets.Wallet), options = quietOptions) + navigator.navigateAll(listOf(AppRoute.Sheets.ActivityHistory), options = quietOptions) navigator.backStack.removeAt(navigator.backStack.lastIndex) navigator.pendingSheetDismiss!!.invoke() assertEquals(2, navigator.sheetGeneration) @@ -166,13 +166,13 @@ class NavigateToTest { val navigator = createNavigator( AppRoute.Main.Scanner, AppRoute.Main.Sheet( - AppRoute.Sheets.Wallet, + AppRoute.Sheets.ActivityHistory, listOf(AppRoute.Token.Info(mint, fromDeeplink = true)), ), ) navigator.navigateAll( - listOf(AppRoute.Sheets.Wallet, AppRoute.Token.Info(mint, fromDeeplink = true)), + listOf(AppRoute.Sheets.ActivityHistory, AppRoute.Token.Info(mint, fromDeeplink = true)), options = quietOptions, ) @@ -188,10 +188,10 @@ class NavigateToTest { // (which would crash SaveableStateProvider). val navigator = createNavigator( AppRoute.Main.Scanner, - AppRoute.Main.Sheet(AppRoute.Sheets.Wallet), + AppRoute.Main.Sheet(AppRoute.Sheets.ActivityHistory), ) - navigator.navigateAll(listOf(AppRoute.Sheets.Wallet), options = quietOptions) + navigator.navigateAll(listOf(AppRoute.Sheets.ActivityHistory), options = quietOptions) // Simulate: a route is pushed during the dismiss animation navigator.backStack.add(AppRoute.Menu.MyAccount) @@ -211,17 +211,17 @@ class NavigateToTest { fun `dismiss-replace increments sheetGeneration for composition scoping`() { val navigator = createNavigator( AppRoute.Main.Scanner, - AppRoute.Main.Sheet(AppRoute.Sheets.Wallet), + AppRoute.Main.Sheet(AppRoute.Sheets.ActivityHistory), ) - navigator.navigateAll(listOf(AppRoute.Sheets.Wallet), options = quietOptions) + navigator.navigateAll(listOf(AppRoute.Sheets.ActivityHistory), options = quietOptions) // Simulate ModalBottomSheetScene dismiss: remove old sheet, then fire callback navigator.backStack.removeAt(navigator.backStack.lastIndex) navigator.pendingSheetDismiss!!.invoke() val newSheet = navigator.backStack.filterIsInstance().single() - assertEquals(AppRoute.Sheets.Wallet, newSheet.initialRoute) + assertEquals(AppRoute.Sheets.ActivityHistory, newSheet.initialRoute) assert(navigator.sheetGeneration > 0) { "Expected sheetGeneration > 0 after dismiss-replace, got ${navigator.sheetGeneration}" } @@ -229,19 +229,19 @@ class NavigateToTest { @Test fun `navigate deduplicates identical sheet that was not removed by onBack`() { - // Reproduces the production crash: a stale Sheet(Wallet,[]) remains on the + // Reproduces the production crash: a stale Sheet(ActivityHistory,[]) remains on the // backstack after onBack removed the wrong entry during a dismiss animation. // A subsequent navigate for the same Sheet must not produce a duplicate. val navigator = createNavigator( AppRoute.Main.Scanner, - AppRoute.Main.Sheet(AppRoute.Sheets.Wallet), + AppRoute.Main.Sheet(AppRoute.Sheets.ActivityHistory), ) // Simulate: something pushed on top during dismiss, onBack removed that // instead of the sheet, so the old sheet is still here. // Now navigate to the same sheet again. navigator.navigate( - AppRoute.Main.Sheet(AppRoute.Sheets.Wallet), + AppRoute.Main.Sheet(AppRoute.Sheets.ActivityHistory), NavOptions(debugRouting = false), ) @@ -253,7 +253,7 @@ class NavigateToTest { fun `double navigateTo with pending dismiss does not produce duplicate sheets`() { val navigator = createNavigator( AppRoute.Main.Scanner, - AppRoute.Main.Sheet(AppRoute.Sheets.Wallet), + AppRoute.Main.Sheet(AppRoute.Sheets.ActivityHistory), ) // First navigate sets pendingSheetDismiss @@ -285,7 +285,7 @@ class NavigateToTest { fun `openAsSheet with existing sheet sets pendingSheetDismiss`() { val navigator = createNavigator( AppRoute.Main.Scanner, - AppRoute.Main.Sheet(AppRoute.Sheets.Wallet), + AppRoute.Main.Sheet(AppRoute.Sheets.ActivityHistory), ) navigator.openAsSheet(AppRoute.Sheets.ShareApp) @@ -299,7 +299,7 @@ class NavigateToTest { fun `openAsSheet callback navigates to new sheet after dismiss`() { val navigator = createNavigator( AppRoute.Main.Scanner, - AppRoute.Main.Sheet(AppRoute.Sheets.Wallet), + AppRoute.Main.Sheet(AppRoute.Sheets.ActivityHistory), ) navigator.openAsSheet(AppRoute.Sheets.ShareApp) @@ -317,7 +317,7 @@ class NavigateToTest { fun `openAsSheet increments sheetGeneration on dismiss-replace`() { val navigator = createNavigator( AppRoute.Main.Scanner, - AppRoute.Main.Sheet(AppRoute.Sheets.Wallet), + AppRoute.Main.Sheet(AppRoute.Sheets.ActivityHistory), ) val initialGeneration = navigator.sheetGeneration diff --git a/apps/flipcash/shared/router/src/test/kotlin/com/flipcash/app/router/internal/ResolveRoutesTest.kt b/apps/flipcash/shared/router/src/test/kotlin/com/flipcash/app/router/internal/ResolveRoutesTest.kt index b9a1a2aa96..d236289719 100644 --- a/apps/flipcash/shared/router/src/test/kotlin/com/flipcash/app/router/internal/ResolveRoutesTest.kt +++ b/apps/flipcash/shared/router/src/test/kotlin/com/flipcash/app/router/internal/ResolveRoutesTest.kt @@ -14,13 +14,14 @@ import org.robolectric.annotation.Config import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertIs -import kotlin.test.assertNotEquals import kotlin.test.assertTrue @RunWith(RobolectricTestRunner::class) @Config(manifest = Config.NONE) class ResolveRoutesTest { + private val mint = Mint("So11111111111111111111111111111111111111112") + // region Empty / non-sheet routes @Test @@ -36,23 +37,22 @@ class ResolveRoutesTest { // endregion - // region Sheet wrapping + // region Sheet wrapping — genuine modals (no tab home) bundle into Main.Sheet @Test fun `single sheet route is wrapped in Main Sheet`() { - val resolved = resolveRoutes(listOf(AppRoute.Sheets.Wallet)) + val resolved = resolveRoutes(listOf(AppRoute.Sheets.ActivityHistory)) assertEquals(1, resolved.size) val sheet = resolved.single() assertIs(sheet) - assertEquals(AppRoute.Sheets.Wallet, sheet.initialRoute) + assertEquals(AppRoute.Sheets.ActivityHistory, sheet.initialRoute) assertEquals(emptyList(), sheet.innerRoutes) } @Test fun `sheet with inner routes bundles into Main Sheet`() { - val mint = Mint("So11111111111111111111111111111111111111112") val routes = listOf( - AppRoute.Sheets.Wallet, + AppRoute.Sheets.ActivityHistory, AppRoute.Token.Info(mint, fromDeeplink = true), ) @@ -60,7 +60,7 @@ class ResolveRoutesTest { assertEquals(1, resolved.size) val sheet = resolved.single() assertIs(sheet) - assertEquals(AppRoute.Sheets.Wallet, sheet.initialRoute) + assertEquals(AppRoute.Sheets.ActivityHistory, sheet.initialRoute) assertEquals(1, sheet.innerRoutes.size) assertIs(sheet.innerRoutes[0]) } @@ -68,7 +68,7 @@ class ResolveRoutesTest { @Test fun `sheet with multiple inner routes bundles all`() { val routes = listOf( - AppRoute.Sheets.Menu, + AppRoute.Sheets.ActivityHistory, AppRoute.Menu.MyAccount, AppRoute.Verification( origin = AppRoute.Menu.MyAccount, @@ -82,7 +82,7 @@ class ResolveRoutesTest { assertEquals(1, resolved.size) val sheet = resolved.single() assertIs(sheet) - assertEquals(AppRoute.Sheets.Menu, sheet.initialRoute) + assertEquals(AppRoute.Sheets.ActivityHistory, sheet.initialRoute) assertEquals(2, sheet.innerRoutes.size) assertIs(sheet.innerRoutes[0]) assertIs(sheet.innerRoutes[1]) @@ -92,8 +92,8 @@ class ResolveRoutesTest { fun `routes before sheet stay on root backstack`() { val routes = listOf( AppRoute.Main.Scanner, - AppRoute.Sheets.Wallet, - AppRoute.Token.Info(Mint("So11111111111111111111111111111111111111112")), + AppRoute.Sheets.ActivityHistory, + AppRoute.Token.Info(mint), ) val resolved = resolveRoutes(routes) @@ -101,7 +101,7 @@ class ResolveRoutesTest { assertIs(resolved[0]) val sheet = resolved[1] assertIs(sheet) - assertEquals(AppRoute.Sheets.Wallet, sheet.initialRoute) + assertEquals(AppRoute.Sheets.ActivityHistory, sheet.initialRoute) assertEquals(1, sheet.innerRoutes.size) } @@ -111,9 +111,8 @@ class ResolveRoutesTest { @Test fun `resolved routes are structurally equal when inputs match`() { - val mint = Mint("So11111111111111111111111111111111111111112") val routes = listOf( - AppRoute.Sheets.Wallet, + AppRoute.Sheets.ActivityHistory, AppRoute.Token.Info(mint, fromDeeplink = true), ) @@ -127,43 +126,43 @@ class ResolveRoutesTest { val mintA = Mint("So11111111111111111111111111111111111111112") val mintB = Mint("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v") - val resolvedA = resolveRoutes(listOf(AppRoute.Sheets.Wallet, AppRoute.Token.Info(mintA))) - val resolvedB = resolveRoutes(listOf(AppRoute.Sheets.Wallet, AppRoute.Token.Info(mintB))) + val resolvedA = + resolveRoutes(listOf(AppRoute.Sheets.ActivityHistory, AppRoute.Token.Info(mintA))) + val resolvedB = + resolveRoutes(listOf(AppRoute.Sheets.ActivityHistory, AppRoute.Token.Info(mintB))) assert(resolvedA != resolvedB) } // endregion - // region v2 (isNewUi) — tab homes must stay flat, not become sheets - - private val mint = Mint("So11111111111111111111111111111111111111112") + // region Tab homes must stay flat, not become sheets @Test - fun `v2 keeps wallet tab flat and pushes token info on top`() { + fun `wallet tab stays flat and token info pushes on top`() { val routes = listOf( AppRoute.Sheets.Wallet, AppRoute.Token.Info(mint, fromDeeplink = true), ) - val resolved = resolveRoutes(routes, isNewUi = true) + val resolved = resolveRoutes(routes) assertEquals(routes, resolved) assertTrue(resolved.none { it is AppRoute.Main.Sheet }) } @Test - fun `v2 keeps chats tab flat and pushes chat on top`() { + fun `chats tab stays flat and the chat pushes on top`() { val routes = listOf( AppRoute.Sheets.Tips(), AppRoute.Messaging.Chat(ChatIdentifier.ByChatId(ChatId(listOf(1, 2, 3, 4)))), ) - val resolved = resolveRoutes(routes, isNewUi = true) + val resolved = resolveRoutes(routes) assertEquals(routes, resolved) assertTrue(resolved.none { it is AppRoute.Main.Sheet }) } @Test - fun `v2 keeps menu tab flat with my account and verification pushed on top`() { + fun `menu tab stays flat with my account and verification pushed on top`() { val routes = listOf( AppRoute.Sheets.Menu, AppRoute.Menu.MyAccount, @@ -175,27 +174,18 @@ class ResolveRoutesTest { ), ) - assertEquals(routes, resolveRoutes(routes, isNewUi = true)) - } - - @Test - fun `v2 still wraps a non-tab sheet`() { - // ActivityHistory is a genuine modal in both shells — it has no tab. - val routes = listOf(AppRoute.Sheets.ActivityHistory) - val resolved = resolveRoutes(routes, isNewUi = true) - assertEquals(1, resolved.size) - assertIs(resolved.single()) + assertEquals(routes, resolveRoutes(routes)) } @Test - fun `v2 wraps a genuine sheet that follows a tab home`() { + fun `a genuine sheet that follows a tab home still wraps`() { val routes = listOf( AppRoute.Sheets.Wallet, AppRoute.Token.Info(mint), AppRoute.Sheets.ActivityHistory, ) - val resolved = resolveRoutes(routes, isNewUi = true) + val resolved = resolveRoutes(routes) assertEquals(3, resolved.size) assertEquals(AppRoute.Sheets.Wallet, resolved[0]) assertIs(resolved[1]) @@ -204,62 +194,41 @@ class ResolveRoutesTest { assertEquals(AppRoute.Sheets.ActivityHistory, sheet.initialRoute) } - @Test - fun `v1 and v2 disagree only on tab homes`() { - val routes = listOf(AppRoute.Sheets.Wallet, AppRoute.Token.Info(mint)) - assertNotEquals(resolveRoutes(routes, isNewUi = false), resolveRoutes(routes, isNewUi = true)) - - // No sheet at all -> identical in both shells. - val plain = listOf(AppRoute.Main.Scanner, AppRoute.Menu.MyAccount) - assertEquals(resolveRoutes(plain, isNewUi = false), resolveRoutes(plain, isNewUi = true)) - } - // endregion // region resolveBackStack — tab switch replaces the base stack @Test - fun `v2 deeplink to a tab replaces the launch home rather than stacking on it`() { + fun `a deeplink to a tab replaces the launch home rather than stacking on it`() { val base = listOf(AppRoute.Sheets.Wallet) val deeplink = listOf( AppRoute.Sheets.Tips(), AppRoute.Messaging.Chat(ChatIdentifier.ByChatId(ChatId(listOf(9)))), ) - val stack = resolveBackStack(base, deeplink, isNewUi = true) + val stack = resolveBackStack(base, deeplink) assertEquals(deeplink, stack) // The Wallet home the app launched on must not linger beneath the Chats tab. assertTrue(stack.none { it == AppRoute.Sheets.Wallet }) } @Test - fun `v2 token deeplink lands on the wallet tab exactly once`() { + fun `a token deeplink lands on the wallet tab exactly once`() { val base = listOf(AppRoute.Sheets.Wallet) val deeplink = listOf(AppRoute.Sheets.Wallet, AppRoute.Token.Info(mint, fromDeeplink = true)) - val stack = resolveBackStack(base, deeplink, isNewUi = true) + val stack = resolveBackStack(base, deeplink) assertEquals(2, stack.size) assertEquals(1, stack.count { it == AppRoute.Sheets.Wallet }) assertIs(stack[1]) } @Test - fun `v2 deeplink without a tab home stacks on the launch home`() { + fun `a deeplink without a tab home stacks on the launch home`() { val base = listOf(AppRoute.Sheets.Wallet) val deeplink = listOf(AppRoute.Token.Info(mint), AppRoute.Token.Swap(SwapPurpose.Buy(mint))) - assertEquals(base + deeplink, resolveBackStack(base, deeplink, isNewUi = true)) - } - - @Test - fun `v1 backstack is unchanged by resolveBackStack`() { - val base = listOf(AppRoute.Main.Scanner) - val deeplink = listOf(AppRoute.Sheets.Wallet, AppRoute.Token.Info(mint)) - - val stack = resolveBackStack(base, deeplink, isNewUi = false) - assertEquals(2, stack.size) - assertEquals(AppRoute.Main.Scanner, stack[0]) - assertIs(stack[1]) + assertEquals(base + deeplink, resolveBackStack(base, deeplink)) } // endregion 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 6522289d0c..4fd7206760 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 @@ -46,7 +46,6 @@ import com.getcode.utils.trace import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.flatMapLatest @@ -252,12 +251,8 @@ class RealSessionController @Inject constructor( .onEach { enabled -> stateHolder.update { it.copy(vibrateOnScan = enabled) } } .launchIn(scope) - // Re-evaluate on balance changes and on NewUi toggles — hasGiveableBalance() only counts - // USDF as giveable in the new UI. - combine( - tokenCoordinator.tokenBalances, - featureFlagController.observe(FeatureFlag.NewUi), - ) { _, _ -> tokenCoordinator.hasGiveableBalance() } + tokenCoordinator.tokenBalances + .map { tokenCoordinator.hasGiveableBalance() } .distinctUntilChanged() .onEach { hasBalance -> stateHolder.update { it.copy(hasGiveableBalance = hasBalance) } } .launchIn(scope) 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 a8671914c9..975bc49ee4 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,8 +10,6 @@ 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.libs.coroutines.DispatcherProvider import com.flipcash.app.tokens.core.ReservesBalanceProvider @@ -110,7 +108,6 @@ class TokenCoordinator @Inject constructor( private val exchange: Exchange, private val verifiedFiatCalculator: VerifiedFiatCalculator, private val dataSource: TokenDataSource, - private val featureFlags: FeatureFlagController, private val dispatchers: DispatcherProvider, ) : TokenMetadataProvider, SessionListener, DefaultLifecycleObserver, ReservesBalanceProvider { @@ -214,12 +211,6 @@ class TokenCoordinator @Inject constructor( exchange.updateUserMints(mints) } .launchIn(scope) - - // USDF givability is tied to the new UI; re-evaluate the selected token when it toggles. - featureFlags.observe(FeatureFlag.NewUi) - .filter { _hydrated.value } - .onEach { ensureValidTokenSelection() } - .launchIn(scope) } override fun onStart(owner: LifecycleOwner) { @@ -239,10 +230,8 @@ class TokenCoordinator @Inject constructor( /** Can I hand money to a person right now? */ suspend fun hasGiveableBalance(atLeast: Fiat = Fiat.Zero): Boolean { - // USDF is only giveable in the new UI; otherwise a USDF-only balance must not count. - val canGiveUsdf = featureFlags.get(FeatureFlag.NewUi) val state = _state.value - return state.balances.filterKeys { canGiveUsdf || it != Mint.usdf } + return state.balances .values .any { balance -> if (atLeast > Fiat.Zero) balance.valueGreaterThanOrEqualTo(atLeast) @@ -605,15 +594,10 @@ class TokenCoordinator @Inject constructor( ?.get(mintPreferenceKey) ?.let { Mint(it) } - // USDF is a valid selection only in the new UI (where it's giveable). - val canGiveUsdf = featureFlags.get(FeatureFlag.NewUi) - 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/ui/CurrencyCreatorUpsellCard.kt b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/CurrencyCreatorUpsellCard.kt deleted file mode 100644 index 6181a246b8..0000000000 --- a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/CurrencyCreatorUpsellCard.kt +++ /dev/null @@ -1,144 +0,0 @@ -package com.flipcash.app.tokens.ui - -import androidx.compose.foundation.Image -import androidx.compose.foundation.border -import androidx.compose.foundation.layout.Arrangement -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.text.TextAutoSize -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowForward -import androidx.compose.material3.Icon -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.lerp -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.tooling.preview.PreviewWrapper -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import com.flipcash.app.theme.FlipcashThemeWrapper -import com.flipcash.app.theme.MultiDevicePreview -import com.flipcash.shared.tokens.R -import com.getcode.theme.CodeTheme -import dev.chrisbanes.haze.HazeInput -import dev.chrisbanes.haze.HazeState -import dev.chrisbanes.haze.blur.HazeBlurStyle -import dev.chrisbanes.haze.blur.HazeColorEffect -import dev.chrisbanes.haze.blur.hazeBlur - -@Composable -fun CurrencyCreatorUpsellCard( - modifier: Modifier = Modifier, - hazeState: HazeState? = null, - onClick: () -> Unit, -) { - val shape = CodeTheme.shapes.medium - - // When a HazeState is supplied the card frosts whatever list content scrolls beneath it (iOS - // "liquid glass"), matching the v2 navigation pill: a wide blur plus a strong tint toward the - // BACKGROUND colour at high alpha, finished with a faint bright rim. `clip` must precede - // `hazeBlur` so the blur is bounded to the rounded card, not its bounding box. Falls back to the - // opaque surface when no HazeState is supplied (e.g. when the card is itself part of the list). - val backdrop = CodeTheme.colors.background - val glassTint = lerp(backdrop, Color.White, 0.18f) - // The HazeBlurStyle builder is not a @Composable scope, so theme reads are hoisted above it. - val liquidGlass = HazeBlurStyle { - blurRadius(32.dp) - backgroundColor(backdrop) - colorEffects(listOf(HazeColorEffect.tint(glassTint.copy(alpha = 0.72f)))) - } - val glassBackground = if (hazeState != null) { - Modifier - .clip(shape) - .hazeBlur(HazeInput.Sources(hazeState), liquidGlass) - .border(CodeTheme.dimens.border, Color.White.copy(alpha = 0.08f), shape) - } else { - Modifier - } - - Surface( - modifier = modifier.then(glassBackground), - color = if (hazeState != null) Color.Transparent else CodeTheme.colors.surfaceVariant, - contentColor = CodeTheme.colors.textMain, - shape = shape, - tonalElevation = 0.dp, - shadowElevation = 0.dp, - onClick = onClick - ) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.Bottom - ) { - val screenWidth = CodeTheme.dimens.screenWidth - val imageWidthFraction = when { - screenWidth < 360.dp -> 0.28f - screenWidth < 420.dp -> 0.33f - else -> 0.38f - } - - Column( - modifier = Modifier - .weight(1f) - .padding( - start = CodeTheme.dimens.grid.x3, - top = CodeTheme.dimens.grid.x3, - bottom = CodeTheme.dimens.grid.x3, - ) - .align(Alignment.CenterVertically), - verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1) - ) { - Text( - modifier = Modifier.weight(1f, fill = false), - text = stringResource(R.string.action_createYourOwnCurrency), - style = CodeTheme.typography.screenTitle, - color = CodeTheme.colors.textMain, - maxLines = 1, - autoSize = TextAutoSize.StepBased(minFontSize = 11.sp), - ) - Icon( - modifier = Modifier.size(CodeTheme.dimens.staticGrid.x4), - imageVector = Icons.AutoMirrored.Default.ArrowForward, - contentDescription = null, - tint = CodeTheme.colors.textMain, - ) - } - - Text( - text = stringResource(R.string.subtitle_createYourOwnCurrency), - style = CodeTheme.typography.textSmall, - color = CodeTheme.colors.textSecondary, - ) - } - - Image( - modifier = Modifier - .fillMaxWidth(imageWidthFraction), - painter = painterResource(R.drawable.ic_bill_previews), - contentDescription = null, - contentScale = ContentScale.FillWidth, - ) - } - } -} - -@MultiDevicePreview -@PreviewWrapper(FlipcashThemeWrapper::class) -@Composable -private fun Preview_CurrencyCreatorUpsellCard() { - CurrencyCreatorUpsellCard { } -} \ No newline at end of file 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 1eb883a365..e3b0984023 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 @@ -3,11 +3,8 @@ package com.flipcash.app.tokens.ui import androidx.lifecycle.viewModelScope import com.flipcash.app.core.AppRoute import com.flipcash.app.core.tokens.TokenPurpose -import com.flipcash.app.featureflags.FeatureFlag -import com.flipcash.app.featureflags.FeatureFlagController import com.flipcash.app.tokens.TokenCoordinator import com.flipcash.app.tokens.TokenSyncState -import com.flipcash.shared.tokens.R import com.getcode.opencode.exchange.Exchange import com.getcode.opencode.model.financial.Fiat import com.getcode.opencode.model.financial.LocalFiat @@ -17,7 +14,6 @@ import com.getcode.opencode.model.financial.sum import com.getcode.opencode.model.financial.toFiat import com.getcode.solana.keys.Mint import com.getcode.solana.keys.base58 -import com.getcode.util.resources.ResourceHelper import com.flipcash.libs.coroutines.DispatcherProvider import com.getcode.view.BaseViewModel import dagger.hilt.android.lifecycle.HiltViewModel @@ -35,9 +31,7 @@ import javax.inject.Inject class SelectTokenViewModel @Inject constructor( tokenCoordinator: TokenCoordinator, exchange: Exchange, - resources: ResourceHelper, dispatchers: DispatcherProvider, - featureFlags: FeatureFlagController, ) : BaseViewModel( initialState = State(purpose = TokenPurpose.Balance), updateStateForEvent = updateStateForEvent, @@ -47,7 +41,6 @@ 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, @@ -104,7 +97,6 @@ class SelectTokenViewModel @Inject constructor( data class OpenScreen(val route: AppRoute) : Event - data class OnCanGiveUsdf(val enabled: Boolean) : Event data class OnSyncStateChanged(val syncState: TokenSyncState) : Event } @@ -115,11 +107,6 @@ class SelectTokenViewModel @Inject constructor( .onEach { dispatchEvent(Event.OnRateChanged(it)) } .launchIn(viewModelScope) - // USDF givability is tied to the new UI. - featureFlags.observe(FeatureFlag.NewUi) - .onEach { dispatchEvent(Event.OnCanGiveUsdf(it)) } - .launchIn(viewModelScope) - eventFlow .filterIsInstance() .map { it.purpose } @@ -169,15 +156,7 @@ class SelectTokenViewModel @Inject constructor( is TokenPurpose.Tip, TokenPurpose.Deposit, TokenPurpose.Withdraw -> { - if (it.token.address == Mint.usdf) { - if (featureFlags.get(FeatureFlag.NewUi)) { - it.token.name - } else { - resources.getString(R.string.displayName_usdf) - } - } else { - it.token.name - } + it.token.name } is TokenPurpose.Select -> it.token.name @@ -198,13 +177,7 @@ class SelectTokenViewModel @Inject constructor( // show all tokens we have accounts for as deposit targets TokenPurpose.Deposit -> true - is TokenPurpose.Select -> { - if (it.token.address == Mint.usdf) { - stateFlow.value.canGiveUsdf && hasBalance - } else { - hasBalance - } - } + is TokenPurpose.Select -> hasBalance is TokenPurpose.LaunchFunding -> { hasBalance @@ -273,7 +246,6 @@ 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) } is Event.OnSyncStateChanged -> { state -> state.copy(syncState = event.syncState) } } } diff --git a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SwapViewModel.kt b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SwapViewModel.kt index 63a177f07f..6cf3c34a69 100644 --- a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SwapViewModel.kt +++ b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SwapViewModel.kt @@ -11,8 +11,6 @@ import com.flipcash.app.core.extensions.to import com.flipcash.app.core.onramp.ui.buildPhantomButtonLabel import com.flipcash.app.core.tokens.FundingSource import com.flipcash.app.core.tokens.SwapPurpose -import com.flipcash.app.featureflags.FeatureFlag -import com.flipcash.app.featureflags.FeatureFlagController import com.flipcash.app.onramp.CoinbaseOnRampController import com.flipcash.app.onramp.CoinbaseOnRampState import com.flipcash.app.onramp.DeeplinkError @@ -118,7 +116,6 @@ class SwapViewModel @Inject constructor( private val phantomWalletController: PhantomWalletController, private val userFlags: UserFlagsCoordinator, private val usdcDepositSweep: UsdcDepositSweep, - private val featureFlags: FeatureFlagController, dispatchers: DispatcherProvider, ) : BaseViewModel( initialState = State(), @@ -269,7 +266,6 @@ class SwapViewModel @Inject constructor( val fundingTokenWithBalance: TokenWithBalance? = null, // Convert only: the currency the conversion lands in. `tokenWithBalance` is the source. val destinationTokenWithBalance: TokenWithBalance? = null, - val newUiEnabled: Boolean = false, ) { val sellFee: Double? get() { @@ -315,12 +311,12 @@ class SwapViewModel @Inject constructor( get() = (purpose as? SwapPurpose.Buy)?.fundingSource /** - * The v2 "Get" flow: a direct buy with the payment source picked inline on the amount - * screen rather than on a pushed step afterwards. Adding money from an external source - * (Coinbase/Phantom) keeps its own flow either way. + * The "Get" flow: a direct buy with the payment source picked inline on the amount screen + * rather than on a pushed step afterwards. Adding money from an external source + * (Coinbase/Phantom) keeps its own flow. */ val isGet: Boolean - get() = newUiEnabled && purpose is SwapPurpose.Buy && !isAddingMoney + get() = purpose is SwapPurpose.Buy && !isAddingMoney /** The currency a Get is paid from. Null until the default is seeded or one is picked. */ val fundingMint: Mint? @@ -411,7 +407,6 @@ class SwapViewModel @Inject constructor( * which prices the buy and advances to the receipt — this only re-points the entry cap. */ data class OnFundingSourceResolved(val token: TokenWithBalance) : Event - data class OnNewUiChanged(val enabled: Boolean) : Event // endregion // region convert @@ -688,18 +683,13 @@ class SwapViewModel @Inject constructor( } init { - featureFlags.observe(FeatureFlag.NewUi) - .onEach { dispatchEvent(Event.OnNewUiChanged(it)) } - .launchIn(viewModelScope) - - // v2 Get seeds a payment source up front so the amount screen can cap entry and price the - // fee before anything is confirmed. Dollars is the house default; failing that, whichever - // held currency goes furthest. v1 leaves this null and asks after the amount instead. + // Get seeds a payment source up front so the amount screen can cap entry and price the fee + // before anything is confirmed. Dollars is the house default; failing that, whichever held + // currency goes furthest. eventFlow.filterIsInstance() .map { it.purpose } .filterIsInstance() .filter { it.fundingSource == FundingSource.Flexible } - .filter { featureFlags.get(FeatureFlag.NewUi) } .onEach { purpose -> val spendable = tokenCoordinator.tokenBalances .first { it.isNotEmpty() } @@ -2012,7 +2002,6 @@ class SwapViewModel @Inject constructor( is Event.OnFundingTokenSelected -> { state -> state } is Event.OnFundingTokenResolved -> { state -> state.copy(fundingTokenWithBalance = event.token) } - is Event.OnNewUiChanged -> { state -> state.copy(newUiEnabled = event.enabled) } is Event.OnFundingSourceResolved -> { state -> state.copy(fundingTokenWithBalance = event.token) } diff --git a/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/ui/SwapViewModelErrorTest.kt b/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/ui/SwapViewModelErrorTest.kt index cdb6566643..f8ab546d61 100644 --- a/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/ui/SwapViewModelErrorTest.kt +++ b/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/ui/SwapViewModelErrorTest.kt @@ -3,7 +3,6 @@ package com.flipcash.app.tokens.ui import com.flipcash.shared.transactionhistory.ActivityFeedCoordinator import com.flipcash.app.analytics.FlipcashAnalyticsService import com.flipcash.app.core.tokens.SwapPurpose -import com.flipcash.app.featureflags.NoOpFeatureFlagController import com.flipcash.app.onramp.CoinbaseOnRampController import com.flipcash.app.funding.PurchaseMethodController import com.flipcash.app.tokens.TokenCoordinator @@ -90,6 +89,13 @@ class SwapViewModelErrorTest { // Stub limits StateFlow so init block doesn't NPE on null flow whenever(transactionController.limits).thenReturn(MutableStateFlow(null)) + + // A Get seeds its funding source from the first non-empty balance snapshot. Serve one + // right away so that chain finishes: it awaits the snapshot inside the shared event + // bus's collector, and a collector parked there would stall every later dispatch. The + // relaxed balance isn't displayable, so nothing is actually seeded. + every { tokenCoordinator.tokenBalances } returns + MutableStateFlow(listOf(mockk(relaxed = true))) } @After @@ -114,9 +120,6 @@ class SwapViewModelErrorTest { dispatchers = dispatchers, userFlags = userFlagsCoordinator, usdcDepositSweep = usdcDepositSweep, - // These cases exercise the v1 buy/sell paths; the no-op controller reports every - // flag off, so NewUi stays false without stubbing a StateFlow per test. - featureFlags = NoOpFeatureFlagController, ) } diff --git a/maestro/README.md b/maestro/README.md index 37b13d3cc8..4d0e7b208b 100644 --- a/maestro/README.md +++ b/maestro/README.md @@ -78,7 +78,18 @@ pre-login landing, inner FlowHost steps like the withdrawal wizard) still need a `testTag` on their root — e.g. `login_screen` in `LoginScreenContent.kt`. Sub-element anchors (buttons, lists, inputs) remain plain `testTag`s in the component code — -e.g. `menu_button`, `market_cap_chart`, `chat_message_list`, `send_contact_list`, `keypad_`. +e.g. `market_cap_chart`, `chat_message_list`, `send_contact_list`, `keypad_`. + +Two anchors exist purely for the tests and are worth knowing about: + +- **The tab bar** is icon-only (no labels, no content descriptions), so its four tabs would + otherwise be unaddressable. They carry `nav_scanner`, `nav_wallet`, `nav_chats` and + `nav_tipcard` (`NavigationBar.kt`). Tabs are *replaced* on a single root back stack, so Back + never unwinds between them — `helpers/close_open_sheet.yaml` is how flows get home. +- **`token_info_screen`** is on both the pushed currency-info screen *and* the expanded-card + overlay (`CurrencyInfoExpansion.kt`). Tapping a card in the wallet expands it in place rather + than pushing a screen, so the overlay carries the same anchor and flows don't care which + presentation they got. ## Enabling beta flags from a test @@ -111,27 +122,28 @@ maestro/run.sh maestro/tipping_setup.yaml - `login_logout.yaml` — real seed-login UI + logout (Log Out lives on My Account) - `account_navigation.yaml` — menu → My Account → App Settings - `wallet_token_info.yaml` — wallet → token info + market-cap chart -- `discovery_leaderboard.yaml` — Discover → leaderboard → token info +- `discovery_leaderboard.yaml` — wallet → Discover Currencies → leaderboard → token info - `withdraw.yaml` — menu → Withdraw Money → USDC → amount entry (fund-safe) - `deposit.yaml` — menu → Add Money → Other Wallet → USDC deposit (fund-safe) - `tipping_setup.yaml` — create account → set up tip card → tip card renders -- `tip_chat.yaml` — open the tip conversation from the Tips tab and send a message +- `tip_chat.yaml` — open the tip conversation from the Chats tab and send a message - `blocking.yaml` — block a chat participant from their profile, verify in My Account → Blocked, then unblock (leaves the account clean) - `tip_deeplink.yaml` — open a tip-card deeplink (`TIPCARD_DEEPLINK`) → presents the tip flow (waits for balances to sync first, else the empty-cache state trips the add-money gate) -- `buy.yaml` — token info → Buy → payment currency → confirm-purchase screen (fund-safe) -- `sell.yaml` — token info → Sell → amount entry (fund-safe) -- `currency_creator.yaml` — Discover → Create Your Own Currency → intro + $20 balance gate +- `buy.yaml` — Discover → an unheld currency → Get → amount → receipt (fund-safe) +- `sell.yaml` — wallet → Float → Convert → amount entry (fund-safe) +- `currency_creator.yaml` — wallet → Create a Currency → intro + $20 balance gate - `coinbase_onramp.yaml` — Add Money → Coinbase/Google Pay method → onramp (phone verify); `coinbase_onramp_sandbox_enabled` set so a follow-up can drive a sandbox purchase - Give/bill round-trip, token-info deeplink, screenshot suite (existing) **Scaffolded — pending account provisioning** (flow authored + wired; drop in the account/contact and it runs): -- `usdf_only_gate.yaml` — reserves-only account: tapping Cash routes to Discover ("No Community - Currencies Yet"). Mirrors iOS `GiveDiscoverGateRegressionTests`. Needs `USDF_ONLY_DEEPLINK` - (a dedicated USDF-only account, like iOS's `FLIPCASH_UI_TEST_USDF_ONLY_ACCESS_KEY`). +- `usdf_only_gate.yaml` — reserves-only account: the wallet deck holds nothing giveable and the + "Discover Currencies" tile is the way out. Mirrors iOS `GiveDiscoverGateRegressionTests`. Needs + `USDF_ONLY_DEEPLINK` (a dedicated USDF-only account, like iOS's + `FLIPCASH_UI_TEST_USDF_ONLY_ACCESS_KEY`). ### Two phone-verification paths @@ -158,7 +170,7 @@ and it runs): test is at parity; the sandbox flag + method tag are in place if we later want to go further. **Roadmap (tooling):** -- Buy/Sell/Withdraw past confirmation on a funded account (screens tagged). +- Get/Convert/Withdraw past confirmation on a funded account (screens tagged). - Wire a `flipcash_maestro` Fastlane lane (see below). ## CI diff --git a/maestro/blocking.yaml b/maestro/blocking.yaml index 4cf1490ce5..1ba7719ab9 100644 --- a/maestro/blocking.yaml +++ b/maestro/blocking.yaml @@ -9,8 +9,8 @@ tags: env: BETA_FLAGS: "" -# Open the tip conversation. -- tapOn: "Tips" +# Open the tip conversation (the chats tab). +- tapOn: { id: nav_chats } - extendedWaitUntil: { visible: { id: tips_screen }, timeout: 8000 } - tapOn: { id: send_contact_row, index: 0 } - extendedWaitUntil: { visible: { id: chat_screen }, timeout: 8000 } @@ -27,7 +27,7 @@ tags: # Verify they appear in My Account -> Blocked. - tapOn: { id: action_close, optional: true } - extendedWaitUntil: { visible: { id: scanner_screen }, timeout: 8000 } -- tapOn: { id: menu_button } +- tapOn: { id: nav_tipcard } - extendedWaitUntil: { visible: { id: menu_screen }, timeout: 8000 } - tapOn: "My Account" - extendedWaitUntil: { visible: { id: my_account_screen }, timeout: 8000 } diff --git a/maestro/buy.yaml b/maestro/buy.yaml index eccd7db469..f3649235f8 100644 --- a/maestro/buy.yaml +++ b/maestro/buy.yaml @@ -1,26 +1,32 @@ appId: com.flipcash.app.android -name: "Buy — token info → confirm purchase (fund-safe)" +name: "Get — discovery → token info → confirm (fund-safe)" tags: - smoke - swap --- # Fund-safe: drives the buy flow to the confirmation screen and stops (never confirms). +# +# v2 renames the direct buy to "Get" and only offers it on a currency the account does NOT +# hold — a held currency's info screen shows Give/Convert/Withdraw instead. So this enters +# through Discover (whose top row is a currency the test account doesn't hold) rather than +# through the wallet's own deck. +# +# The payment currency is also picked inline on the amount screen now (seeded with a default), +# so there is no pushed "Select Payment Currency" step between the amount and the receipt. - runFlow: subflows/login_with_deeplink.yaml -- runFlow: subflows/navigate_to_wallet.yaml +- runFlow: subflows/navigate_to_discovery.yaml -- tapOn: "Float" +- tapOn: + id: leaderboard_token_row + index: 0 - extendedWaitUntil: { visible: { id: token_info_screen }, timeout: 8000 } -- tapOn: "Buy" +- tapOn: "Get" - extendedWaitUntil: { visible: { id: swap_screen }, timeout: 8000 } -- assertVisible: "Amount to Buy" -# Minimal amount, then pick a payment currency. +# Minimal amount, then straight to the receipt (funding source is already seeded). - tapOn: { id: keypad_1 } - tapOn: "Next" -- extendedWaitUntil: { visible: { id: token_selection_screen }, timeout: 8000 } -- assertVisible: "Select Payment Currency" -- tapOn: "USDF" # Confirmation — assert and stop (do not confirm the purchase). - extendedWaitUntil: { visible: { id: buy_receipt_screen }, timeout: 8000 } -- assertVisible: "Confirm Purchase" +- assertVisible: "You Get" diff --git a/maestro/currency_creator.yaml b/maestro/currency_creator.yaml index 2184750940..16c127810e 100644 --- a/maestro/currency_creator.yaml +++ b/maestro/currency_creator.yaml @@ -7,9 +7,9 @@ tags: # Fund-safe: opens the creator and confirms the $20-fee balance gate. Never pays. - runFlow: subflows/login_with_deeplink.yaml -- tapOn: "Discover" -- extendedWaitUntil: { visible: { id: discovery_screen }, timeout: 8000 } -- tapOn: "Create Your Own Currency" +# Currency creation is a wallet action tile now -- Discover no longer carries the promo. +- runFlow: subflows/navigate_to_wallet.yaml +- tapOn: "Create a Currency" - extendedWaitUntil: { visible: { id: currency_creator_screen }, timeout: 8000 } - assertVisible: "Create Your Currency" diff --git a/maestro/discovery_leaderboard.yaml b/maestro/discovery_leaderboard.yaml index ebdc091c3b..3a7fc1f028 100644 --- a/maestro/discovery_leaderboard.yaml +++ b/maestro/discovery_leaderboard.yaml @@ -8,11 +8,7 @@ tags: - runFlow: subflows/login_with_deeplink.yaml # Open Discover and assert the leaderboard rendered. -- tapOn: "Discover" -- extendedWaitUntil: - visible: - id: discovery_screen - timeout: 8000 +- runFlow: subflows/navigate_to_discovery.yaml - assertVisible: id: discovery_leaderboard - assertVisible: diff --git a/maestro/helpers/close_open_sheet.yaml b/maestro/helpers/close_open_sheet.yaml index 31d312b230..c1a5013470 100644 --- a/maestro/helpers/close_open_sheet.yaml +++ b/maestro/helpers/close_open_sheet.yaml @@ -1,23 +1,48 @@ appId: com.flipcash.app.android --- -# Return to the scanner, which is always the root behind the single open sheet. Opening a -# destination inside a sheet (e.g. wallet -> token info) pushes it onto that sheet's own nav -# stack, so one dismiss only pops a layer. Press Back until the scanner is revealed: each -# Back pops the sheet's nav stack and the final one dismisses the sheet itself. +# Return to the scanner tab — the common starting point for the flows. # -# Bounded (times) rather than an open while-loop so a stuck sheet can't hang CI, and each -# Back is guarded on the scanner not already showing — so it's a safe no-op on a clean home -# screen and never backs out of the app once the scanner is reached. +# Navigation is tab-centric: the four tabs are swapped on a single root backstack, so the scanner is +# NOT a root that Back unwinds to. Back only pops whatever detail screen or sheet sits above the +# CURRENT tab, and pressing it at a tab home would leave the app. So this is two phases: +# +# 1. Back until the nav bar is showing again (its scanner tab is the probe) — i.e. until whatever +# detail/sheet was open has been dismissed and a tab home is on top. Bounded (times) rather +# than an open while-loop so a stuck sheet can't hang CI, and each Back is guarded on the bar +# not already showing, so this is a safe no-op on a clean tab home and never exits the app. +# 2. Tap the scanner tab, unless the scanner is already the current tab. +# +# One thing precedes both: a wallet card opened as an EXPANDED overlay (v2's card-expand) is not a +# nav entry, and the nav bar stays in the hierarchy behind it (merely faded out) — so the tab probe +# below can't tell it apart from a clean tab home. Collapse it explicitly first. The same guard also +# pops a PUSHED currency-info screen, which is equally correct. +- runFlow: + when: + visible: + id: token_info_screen + commands: + - pressKey: Back + - waitForAnimationToEnd: + timeout: 1500 - repeat: times: 5 commands: - runFlow: when: notVisible: - id: scanner_screen + id: nav_scanner commands: - pressKey: Back - waitForAnimationToEnd: timeout: 1500 +- runFlow: + when: + notVisible: + id: scanner_screen + commands: + - tapOn: + id: nav_scanner + - waitForAnimationToEnd: + timeout: 1500 - assertVisible: id: scanner_screen diff --git a/maestro/open_token_info_deeplink.yaml b/maestro/open_token_info_deeplink.yaml index 4ad60cd051..ec78c60e67 100644 --- a/maestro/open_token_info_deeplink.yaml +++ b/maestro/open_token_info_deeplink.yaml @@ -1,12 +1,13 @@ appId: com.flipcash.app.android -name: "Token Info deeplink → opens & dismisses to scanner" +name: "Token Info deeplink → expands the card & collapses back to the wallet" tags: - tokens --- -# A token deeplink opens the token-info screen (pushed into a sheet's nav stack over the -# scanner) and can be dismissed cleanly back to the scanner. Re-firing the same deeplink is -# a no-op (the app dedupes it), so this verifies the open + dismiss, which is the meaningful -# path. +# A /token deeplink lands on the Wallet tab and opens that currency as its EXPANDED CARD — the +# same overlay a tap on the card in the deck gives (v2's card-expand model) — rather than pushing +# a screen onto a sheet's nav stack. So it dismisses with Back, which collapses the card back into +# the deck and leaves the wallet showing. Re-firing the same deeplink is a no-op (the app dedupes +# it), so this verifies the open + dismiss, which is the meaningful path. - runFlow: subflows/login_with_deeplink.yaml - runFlow: @@ -17,6 +18,18 @@ tags: visible: id: token_info_screen timeout: 8000 +# It opened over the wallet tab, not on a stack of its own. +- assertVisible: + id: wallet_screen -# Dismiss the sheet back to the scanner (unwinds the sheet's nav stack, then dismisses it). +# Collapse the card; the wallet deck is what's left behind. +- pressKey: Back +- extendedWaitUntil: + notVisible: + id: token_info_screen + timeout: 8000 +- assertVisible: + id: wallet_screen + +# And back to the scanner home. - runFlow: helpers/close_open_sheet.yaml diff --git a/maestro/screenshots/groups/03_menu.yaml b/maestro/screenshots/groups/03_menu.yaml index df022a19cc..ae6a027e4f 100644 --- a/maestro/screenshots/groups/03_menu.yaml +++ b/maestro/screenshots/groups/03_menu.yaml @@ -14,17 +14,3 @@ tags: # Return to scanner - runFlow: ../../helpers/close_open_sheet.yaml - -# Screenshot: Share app (tap logo) -- tapOn: - id: flipcash_logo -- extendedWaitUntil: - visible: - id: share_app_screen -- runFlow: - file: ../../helpers/screenshot.yaml - env: - SCREENSHOT_NAME: share_app_screen - -# Return to scanner -- runFlow: ../../helpers/close_open_sheet.yaml diff --git a/maestro/screenshots/groups/06_give.yaml b/maestro/screenshots/groups/06_give.yaml index b3e2be18eb..1fff634fdb 100644 --- a/maestro/screenshots/groups/06_give.yaml +++ b/maestro/screenshots/groups/06_give.yaml @@ -12,21 +12,27 @@ tags: env: SCREENSHOT_NAME: cash_screen -# Return to scanner first, then use pull_out_bill for bill screenshot -- runFlow: ../../helpers/close_open_sheet.yaml +# Screenshot: Cash bill. Already on the give screen, so drive the keypad in place rather than +# re-entering through pull_out_bill (which would navigate here all over again). +- tapOn: + id: "keypad_dot" +- tapOn: + id: "keypad_0" +- tapOn: + id: "keypad_1" +- tapOn: Next - extendedWaitUntil: visible: - id: scanner_screen - -# Screenshot: Cash bill (reuses pull_out_bill subflow) -- runFlow: ../../subflows/pull_out_bill.yaml + id: cash_bill - runFlow: file: ../../helpers/screenshot.yaml env: SCREENSHOT_NAME: cash_bill -# Cancel and return +# Put the bill back. The give screen pops itself once the bill is presented, so cancelling +# reveals the currency-info screen it was opened from. - tapOn: Cancel - extendedWaitUntil: visible: - id: scanner_screen + id: token_info_screen +- runFlow: ../../helpers/close_open_sheet.yaml diff --git a/maestro/sell.yaml b/maestro/sell.yaml index 7eb3fbfcea..976c806b63 100644 --- a/maestro/sell.yaml +++ b/maestro/sell.yaml @@ -1,17 +1,17 @@ appId: com.flipcash.app.android -name: "Sell — token info → amount entry (fund-safe)" +name: "Convert — token info → amount entry (fund-safe)" tags: - smoke - swap --- -# Fund-safe: reaches the sell amount-entry. Reaching the sell confirmation depends on +# Fund-safe: reaches the convert amount-entry. Reaching the convert confirmation depends on # the held balance clearing the minimum + fees, so it's left for a funded account. +# +# v2 has no standalone "Sell": liquidating a currency is a Convert into another one (Dollars +# by default), reached from the held currency's own info screen. - runFlow: subflows/login_with_deeplink.yaml -- runFlow: subflows/navigate_to_wallet.yaml +- runFlow: subflows/open_token_info.yaml -- tapOn: "Float" -- extendedWaitUntil: { visible: { id: token_info_screen }, timeout: 8000 } -- tapOn: "Sell" +- tapOn: "Convert" - extendedWaitUntil: { visible: { id: swap_screen }, timeout: 8000 } -- assertVisible: "Amount to Sell" - assertVisible: { id: keypad_5 } diff --git a/maestro/show_bill_and_put_back_in_wallet.yaml b/maestro/show_bill_and_put_back_in_wallet.yaml index 83585764db..8ce97b117e 100644 --- a/maestro/show_bill_and_put_back_in_wallet.yaml +++ b/maestro/show_bill_and_put_back_in_wallet.yaml @@ -3,6 +3,8 @@ appId: com.flipcash.app.android - runFlow: subflows/login_with_deeplink.yaml - runFlow: subflows/pull_out_bill.yaml - tapOn: Cancel +# Giving is reached from a currency's info screen and the give screen pops itself once the bill is +# presented, so putting the bill back reveals currency-info -- not the scanner. - extendedWaitUntil: visible: - id: "scanner_screen" + id: "token_info_screen" diff --git a/maestro/subflows/navigate_to_discovery.yaml b/maestro/subflows/navigate_to_discovery.yaml new file mode 100644 index 0000000000..88e79fc1a5 --- /dev/null +++ b/maestro/subflows/navigate_to_discovery.yaml @@ -0,0 +1,9 @@ +appId: com.flipcash.app.android +--- +# There is no Discover tab: currency discovery is one of the wallet's action tiles. +- runFlow: navigate_to_wallet.yaml +- tapOn: "Discover Currencies" +- extendedWaitUntil: + visible: + id: discovery_screen + timeout: 8000 diff --git a/maestro/subflows/navigate_to_give.yaml b/maestro/subflows/navigate_to_give.yaml index 148d3d452d..1215a471e4 100644 --- a/maestro/subflows/navigate_to_give.yaml +++ b/maestro/subflows/navigate_to_give.yaml @@ -1,7 +1,9 @@ appId: com.flipcash.app.android --- -- runFlow: ../helpers/close_open_sheet.yaml -- tapOn: Cash +# There is no give/cash tab: giving is an action on a currency you hold, reached from that +# currency's info screen (Wallet tab -> Float -> Give). +- runFlow: open_token_info.yaml +- tapOn: Give - extendedWaitUntil: visible: id: cash_screen diff --git a/maestro/subflows/navigate_to_menu.yaml b/maestro/subflows/navigate_to_menu.yaml index 6d5fcf4805..361a0bd872 100644 --- a/maestro/subflows/navigate_to_menu.yaml +++ b/maestro/subflows/navigate_to_menu.yaml @@ -1,8 +1,9 @@ appId: com.flipcash.app.android --- +# The menu is the "You" tab (tip card + settings), not a button on the scanner. - runFlow: ../helpers/close_open_sheet.yaml - tapOn: - id: menu_button + id: nav_tipcard - extendedWaitUntil: visible: id: menu_screen diff --git a/maestro/subflows/navigate_to_wallet.yaml b/maestro/subflows/navigate_to_wallet.yaml index a89fbd9390..c725c0610f 100644 --- a/maestro/subflows/navigate_to_wallet.yaml +++ b/maestro/subflows/navigate_to_wallet.yaml @@ -1,7 +1,8 @@ appId: com.flipcash.app.android --- - runFlow: ../helpers/close_open_sheet.yaml -- tapOn: Wallet +- tapOn: + id: nav_wallet - extendedWaitUntil: visible: id: wallet_screen diff --git a/maestro/subflows/open_token_info.yaml b/maestro/subflows/open_token_info.yaml index 6b3cb92f7f..522474a6b0 100644 --- a/maestro/subflows/open_token_info.yaml +++ b/maestro/subflows/open_token_info.yaml @@ -1,7 +1,8 @@ appId: com.flipcash.app.android --- - runFlow: ../helpers/close_open_sheet.yaml -- tapOn: Wallet +- tapOn: + id: nav_wallet - extendedWaitUntil: visible: Float - tapOn: Float diff --git a/maestro/subflows/pull_out_bill.yaml b/maestro/subflows/pull_out_bill.yaml index 5af5fed1ab..5d880eea54 100644 --- a/maestro/subflows/pull_out_bill.yaml +++ b/maestro/subflows/pull_out_bill.yaml @@ -1,9 +1,7 @@ appId: com.flipcash.app.android --- -- runFlow: ../helpers/close_open_sheet.yaml -# The give/cash nav button's label is config-driven (GiveButtonLabel: "Give" or "Cash"); -# it currently renders "Cash" (action_cash). -- tapOn: Cash +- runFlow: navigate_to_give.yaml +# The give screen is seeded with the currency it was opened from, so it opens on Float. - extendedWaitUntil: visible: Float - extendedWaitUntil: diff --git a/maestro/subflows/return_to_scanner.yaml b/maestro/subflows/return_to_scanner.yaml index f8e8d7c932..b2b4b182b6 100644 --- a/maestro/subflows/return_to_scanner.yaml +++ b/maestro/subflows/return_to_scanner.yaml @@ -1,7 +1,6 @@ appId: com.flipcash.app.android --- -- back +# Thin alias kept for readability at call sites; the helper already unwinds any open detail/sheet +# before switching to the scanner tab, so no bare `back` is needed (and one would risk leaving the +# app when already at a tab home). - runFlow: ../helpers/close_open_sheet.yaml -- extendedWaitUntil: - visible: - id: scanner_screen diff --git a/maestro/tip_chat.yaml b/maestro/tip_chat.yaml index e67d8a28d1..a25e42def9 100644 --- a/maestro/tip_chat.yaml +++ b/maestro/tip_chat.yaml @@ -9,8 +9,8 @@ tags: env: BETA_FLAGS: "" -# Tips tab lists tip conversations; open the first one. -- tapOn: "Tips" +# The chats tab lists tip conversations; open the first one. +- tapOn: { id: nav_chats } - extendedWaitUntil: { visible: { id: tips_screen }, timeout: 8000 } - assertVisible: { id: send_contact_row } - tapOn: { id: send_contact_row, index: 0 } diff --git a/maestro/tipping_setup.yaml b/maestro/tipping_setup.yaml index b7157ff83e..ac5df7fb4f 100644 --- a/maestro/tipping_setup.yaml +++ b/maestro/tipping_setup.yaml @@ -10,9 +10,9 @@ tags: env: BETA_FLAGS: "" -# Tipping is on by default -> the Tips tab is present. -- assertVisible: "Tips" -- tapOn: "Tips" +# Tipping is on by default -> the chats tab is present. +- assertVisible: { id: nav_chats } +- tapOn: { id: nav_chats } - extendedWaitUntil: { visible: { id: tips_screen }, timeout: 8000 } # The display name is captured during onboarding, so tip setup lands directly on the diff --git a/maestro/usdf_only_gate.yaml b/maestro/usdf_only_gate.yaml index bfcc883f68..f6e1a629be 100644 --- a/maestro/usdf_only_gate.yaml +++ b/maestro/usdf_only_gate.yaml @@ -1,19 +1,25 @@ appId: com.flipcash.app.android -name: "Gate — USDF-only account: give routes to Discover" +name: "Gate — USDF-only account: nothing giveable, steered to Discover" tags: - gate --- # Deterministic gate coverage on a reserves-only account (holds USDF, no community # currency). Mirrors iOS GiveDiscoverGateRegressionTests: with a balance but nothing -# giveable, tapping Cash surfaces "No Community Currencies Yet" and routes to Discover. +# giveable, the account has no currency card to give from and the wallet's own +# "Discover Currencies" tile is the way out. +# +# Giving is per-currency now (Wallet -> currency -> Give), so there is no give tab left to +# tap and no "No Community Currencies Yet" interstitial in front of it — the gate is that +# the deck holds no giveable currency at all. # # Requires env: USDF_ONLY_DEEPLINK (a provisioned USDF-only test account). Until that # account exists this flow is a scaffold — the assertions encode the expected behaviour. - runFlow: subflows/login_usdf_only.yaml -- tapOn: "Cash" -- extendedWaitUntil: - visible: - text: "No Community Currencies Yet" - timeout: 8000 +- runFlow: subflows/navigate_to_wallet.yaml +- assertNotVisible: "Float" - assertVisible: "Discover Currencies" + +- runFlow: subflows/navigate_to_discovery.yaml +- assertVisible: + id: discovery_leaderboard diff --git a/settings.gradle.kts b/settings.gradle.kts index 73dea3dcf8..9ee8d3512b 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -101,7 +101,6 @@ include( ":apps:flipcash:features:menu", ":apps:flipcash:features:purchase", ":apps:flipcash:features:lab", - ":apps:flipcash:features:home", ":apps:flipcash:features:appsettings", ":apps:flipcash:features:appupdates", ":apps:flipcash:features:deposit", From f2399d4296cddb70059eec3c4ba1a407d40804e3 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 21 Aug 2026 17:47:32 -0400 Subject: [PATCH 2/2] chore(tokens): drop the unreachable sell branch from the swap success title (#1301) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tearing out the v1 UI removed the Sell button from the token screen, which was the only place a SwapPurpose.Sell could be constructed. The v2 action row offers Give, Convert, and Withdraw, so nothing can reach the success title as a sell any more. The title's `else` arm existed to name the reserve for that case. Make the two reachable purposes explicit and let the fallback go unnamed rather than claim a destination it can no longer have; the suffix was already optional. title_cashReserves had no other reference, so it goes with it. SwapPurpose.Sell itself stays: it is unreachable but still referenced by SwapViewModel, AppRoute, and the sell receipt screen, so removing the type is a larger change than this one. canSell stays too — Convert reads it. --- apps/flipcash/core/src/main/res/values/strings.xml | 1 - .../app/tokens/internal/TokenTxProcessingScreen.kt | 8 ++++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 7de8270354..f099027088 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -536,7 +536,6 @@ Color Dodge Plus Lighter - USDF Buy More Add Money diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/TokenTxProcessingScreen.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/TokenTxProcessingScreen.kt index a68130b5b7..8c1d484c11 100644 --- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/TokenTxProcessingScreen.kt +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/TokenTxProcessingScreen.kt @@ -130,9 +130,13 @@ private fun TokenTxProcessingScreen( val name = when (state.purpose) { is SwapPurpose.Convert -> state.destinationTokenName is SwapPurpose.BalanceIncrease -> state.tokenName - else -> stringResource(R.string.title_cashReserves) + // Selling is unreachable now that the v1 token screen is gone, and a + // purpose is always set by the time a swap reaches this screen. + else -> null } - state.netTransferAmount.formatted(suffix = stringResource(R.string.label_ofToken, name)) + state.netTransferAmount.formatted( + suffix = name?.let { stringResource(R.string.label_ofToken, it) }, + ) } }, style = CodeTheme.typography.textLarge,