From 613c2eb3b0e3a11faff3feb7e73bde37b2272c40 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 10 Aug 2026 15:43:27 -0400 Subject: [PATCH 1/2] feat(onboarding): deterministic add-money & tip milestones Expose whether a user has ever added money or scanned/sent a tip via DAO EXISTS queries rather than deriving from current balance: - ChatMessageDao.hasEverTipped(selfIdHex): outgoing TIPPED chat messages only (sender == self). - MessageDao.hasEverAddedMoney(): completed DepositedCrypto / BoughtToken activity. - Surfaced through the datasources and the chat/activity-feed coordinators for the wallet onboarding funnel. --- .../flipcash/app/activityfeed/ActivityFeedCoordinator.kt | 3 +++ .../kotlin/com/flipcash/shared/chat/ChatCoordinator.kt | 3 +++ .../shared/chat/internal/delegates/MessagingDelegate.kt | 2 ++ .../com/flipcash/app/persistence/dao/ChatMessageDao.kt | 7 +++++++ .../com/flipcash/app/persistence/dao/MessageDao.kt | 9 +++++++++ .../app/persistence/sources/ChatMessageDataSource.kt | 7 +++++++ .../app/persistence/sources/MessageDataSource.kt | 6 ++++++ 7 files changed, 37 insertions(+) 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 31543ac38..6c2c10981 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 @@ -67,6 +67,9 @@ class ActivityFeedCoordinator @Inject constructor( } } + /** Reactive "has the user ever added money" — any completed deposit/buy in the feed. */ + fun hasEverAddedMoney(): Flow = dataSource.hasEverAddedMoney() + suspend fun checkPendingMessagesForUpdates(): Result { val pendingMessages = dataSource.query(whereClause = "state = '${NotificationState.PENDING.name}'") diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt index 13bd6c149..9ac375aa1 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt @@ -120,6 +120,9 @@ interface MessagingOperations { /** Observes all messages in [chatId] as a flat list. */ fun observeMessages(chatId: ChatId): Flow> + /** True once the user has ever sent a tip (a Cash message with verb TIPPED) — onboarding milestone. */ + fun hasEverTipped(): Flow + /** Observes messages in [chatId] via Paging 3, with remote-mediated page loads. */ fun observeMessagesPaged(chatId: ChatId): Flow> diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt index 551b38c1b..cbe0942cf 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt @@ -90,6 +90,8 @@ class MessagingDelegate @Inject constructor( return messageDataSource.observeMessages(chatId) } + override fun hasEverTipped(): Flow = messageDataSource.hasEverTipped() + override fun observeMessagesPaged(chatId: ChatId): Flow> { return Pager( config = PagingConfig(pageSize = 50), diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMessageDao.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMessageDao.kt index b47741fe9..ee693339e 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMessageDao.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMessageDao.kt @@ -19,6 +19,13 @@ interface ChatMessageDao { @Query("SELECT * FROM chat_messages WHERE chat_id_hex = :chatIdHex ORDER BY timestamp_epoch_ms DESC") fun observeMessagesPaged(chatIdHex: String): PagingSource + /** + * True once the user has sent a tip — an **outgoing** Cash message (sender = self) with verb + * TIPPED. Received tips don't count toward the "scanned a tip card" onboarding milestone. + */ + @Query("SELECT EXISTS(SELECT 1 FROM chat_messages WHERE sender_id_hex = :selfIdHex AND content_json LIKE '%\"action\":\"TIPPED\"%')") + fun hasEverTipped(selfIdHex: String): Flow + @Query("SELECT * FROM chat_messages WHERE chat_id_hex = :chatIdHex ORDER BY timestamp_epoch_ms DESC LIMIT 1") suspend fun getLatest(chatIdHex: String): ChatMessageEntity? diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/MessageDao.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/MessageDao.kt index 2d9bbcf68..65eeaf547 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/MessageDao.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/MessageDao.kt @@ -10,6 +10,7 @@ import androidx.room.Transaction import androidx.sqlite.db.SupportSQLiteQuery import com.flipcash.app.persistence.entities.MessageEntity import com.getcode.utils.base58 +import kotlinx.coroutines.flow.Flow @Dao interface MessageDao { @@ -35,6 +36,14 @@ interface MessageDao { @Query("SELECT * FROM messages") suspend fun getAllMessages(): List + /** True once any completed deposit/buy notification exists — the "added money" milestone. */ + @Query( + "SELECT EXISTS(SELECT 1 FROM messages WHERE state = 'COMPLETED' AND (" + + "metadata LIKE '%com.flipcash.app.core.feed.MessageMetadata.DepositedCrypto%' OR " + + "metadata LIKE '%com.flipcash.app.core.feed.MessageMetadata.BoughtToken%'))" + ) + fun hasEverAddedMoney(): Flow + @Query("DELETE FROM messages") suspend fun deleteAllMessages() } \ No newline at end of file diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMessageDataSource.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMessageDataSource.kt index c8405b0da..1d209d006 100644 --- a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMessageDataSource.kt +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMessageDataSource.kt @@ -17,6 +17,7 @@ import com.getcode.utils.hexEncodedString import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import javax.inject.Inject import javax.inject.Singleton @@ -41,6 +42,12 @@ class ChatMessageDataSource @Inject constructor( activeChatId = chatId } + /** Reactive "has the user ever sent a tip" — an outgoing Cash message (self) with verb TIPPED. */ + fun hasEverTipped(): Flow { + val selfHex = userManager.accountId?.hexEncodedString() ?: return flowOf(false) + return db?.chatMessageDao()?.hasEverTipped(selfHex) ?: flowOf(false) + } + // region PagingDataSource override fun observe(): PagingSource { diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/MessageDataSource.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/MessageDataSource.kt index 354d491f5..93bf33f6b 100644 --- a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/MessageDataSource.kt +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/MessageDataSource.kt @@ -11,6 +11,8 @@ import com.flipcash.app.persistence.sources.mapper.notifications.NotificationToE import com.flipcash.services.models.ActivityFeedNotification import com.flipcash.services.persistence.PagingDataSource import com.getcode.opencode.model.core.ID +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf import javax.inject.Inject import javax.inject.Singleton @@ -53,6 +55,10 @@ class MessageDataSource @Inject constructor( db?.messageDao()?.upsert(*entities.toTypedArray()) } + /** Reactive "has the user ever added money" — any completed deposit/buy notification. */ + fun hasEverAddedMoney(): Flow = + db?.messageDao()?.hasEverAddedMoney() ?: flowOf(false) + override fun observe(): PagingSource { return db?.messageDao()?.observeMessages() ?: object : PagingSource() { override fun getRefreshKey(state: PagingState): Int? = null From 59a5f413de2574d895f9dfb512b8e7a124568b6e Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 10 Aug 2026 15:44:00 -0400 Subject: [PATCH 2/2] =?UTF-8?q?feat(new-ui):=20v2=20tab-bar=20UI=20?= =?UTF-8?q?=E2=80=94=20nav=20bar,=20wallet=20screen,=20token=20cards?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The app-wide v2 UI behind FeatureFlag.NewUi (default on), moving from a sheet-centric model to a tab-bar-centric one. iOS will mirror this, so the structure here is the reference. Navigation / chrome: - Hoist the tab bar to app-root chrome (single NavDisplay, no HomeScreen): AppNavigationBar + NavigationBar v2 with a sliding selected indicator, route-driven selection, crossfade tab transitions, and a bottom-inset (LocalTabBarPadding) provided to screens. Bar hides behind bottom-sheet modals (e.g. deposit options). - NavBarConfig split to a dedicated v2 instance (NavBarButton.v2Order) so FeatureFlag.NavBar can retire with v1; NavBarRoutes maps buttons <-> routes. - New UI launches on the Wallet tab; v1 keeps Scanner (MainRoot isNewUi). Wallet: - WalletScreen renders full-screen in v2 (still a sheet in v1) via the navigation method, not new routes. - Reusable per-token TokenCard (bill-customization colors, gold USDF) and a sticky TokenCardStack that collapses into a deck on scroll. Balance uses AnimatedNumberText (animates + autosizes). OnboardingFunnel wired to the deterministic milestones. Fixes bundled in: - Auth: stop a transient Unknown state committing login and landing an authenticated user on the login screen on relaunch (AuthManager + MainRoot). - Verification: the Intro's Next advanced via proceed() which is a no-op for the non-linear flow; navigate to the phone step explicitly. --- .../com/flipcash/app/internal/ui/App.kt | 123 ++++------- .../app/internal/ui/AppNavigationBar.kt | 84 ++++++++ .../app/internal/ui/navigation/AppContent.kt | 190 +++++++++++++++++ .../ui/navigation/AppScreenContent.kt | 17 +- .../app/internal/ui/navigation/MainRoot.kt | 21 +- .../kotlin/com/flipcash/app/core/AppRoute.kt | 8 + .../app/core/navigation/LocalTabBarPadding.kt | 6 + .../app/core/navigation/NavBarButton.kt | 4 + .../app/core/navigation/NavBarConfig.kt | 21 +- .../app/core/navigation/NavBarRoutes.kt | 25 +++ .../com/flipcash/app/core/ui/NavigationBar.kt | 195 ++++++++++++++++-- .../com/flipcash/app/core/ui/TokenCard.kt | 165 +++++++++++++++ .../flipcash/app/core/ui/TokenCardStack.kt | 66 ++++++ .../app/core/verification/VerificationStep.kt | 4 + .../src/main/res/drawable/ic_nav_chat.xml | 19 ++ .../src/main/res/drawable/ic_nav_scan.xml | 25 +++ .../src/main/res/drawable/ic_nav_tipcard.xml | 50 +++++ .../src/main/res/drawable/ic_nav_wallet.xml | 13 ++ .../core/src/main/res/values/strings.xml | 6 + .../features/balance/build.gradle.kts | 2 + .../com/flipcash/app/balance/WalletScreen.kt | 57 +++++ .../app/balance/internal/BalanceViewModel.kt | 35 +++- .../balance/internal/WalletScreenContent.kt | 177 ++++++++++++++++ .../internal/components/OnboardingFunnel.kt | 155 ++++++++++++++ .../verification/VerificationFlowScreen.kt | 4 + .../internal/VerificationIntroScreen.kt | 150 ++++++++++++++ apps/flipcash/features/home/.gitignore | 2 + apps/flipcash/features/home/build.gradle.kts | 21 ++ .../com/flipcash/app/home/HomeScreen.kt | 47 +++++ .../app/lab/internal/NavBarSettingsContent.kt | 4 +- .../internal/bills/ScannableContainer.kt | 1 - .../ui/components/ScannerNavigationBar.kt | 30 ++- .../com/flipcash/app/auth/AuthManager.kt | 7 +- .../flipcash/app/featureflags/FeatureFlag.kt | 11 + .../internal/RegionSelectionScreen.kt | 3 +- .../app/tokens/ui/SelectTokenViewModel.kt | 18 +- settings.gradle.kts | 1 + 37 files changed, 1642 insertions(+), 125 deletions(-) create mode 100644 apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/AppNavigationBar.kt create mode 100644 apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppContent.kt create mode 100644 apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/LocalTabBarPadding.kt create mode 100644 apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/NavBarRoutes.kt create mode 100644 apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/ui/TokenCard.kt create mode 100644 apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/ui/TokenCardStack.kt create mode 100644 apps/flipcash/core/src/main/res/drawable/ic_nav_chat.xml create mode 100644 apps/flipcash/core/src/main/res/drawable/ic_nav_scan.xml create mode 100644 apps/flipcash/core/src/main/res/drawable/ic_nav_tipcard.xml create mode 100644 apps/flipcash/core/src/main/res/drawable/ic_nav_wallet.xml create mode 100644 apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/WalletScreen.kt create mode 100644 apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/WalletScreenContent.kt create mode 100644 apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/components/OnboardingFunnel.kt create mode 100644 apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/internal/VerificationIntroScreen.kt create mode 100644 apps/flipcash/features/home/.gitignore create mode 100644 apps/flipcash/features/home/build.gradle.kts create mode 100644 apps/flipcash/features/home/src/main/kotlin/com/flipcash/app/home/HomeScreen.kt 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 51356c810..8d2394537 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 @@ -37,10 +37,16 @@ import com.flipcash.app.core.AppRoute 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 @@ -80,6 +86,7 @@ import kotlinx.coroutines.flow.first internal fun App( tipsEngine: TipsEngine, ) { + val features = LocalFeatureFlags.current val router = LocalRouter.current!! val analytics = rememberAnalytics() val viewModel = getActivityScopedViewModel() @@ -110,6 +117,8 @@ internal fun App( val session = LocalSessionController.current!! val userState by userManager.state.collectAsStateWithLifecycle() + val isNewUi by features.observe(FeatureFlag.NewUi).collectAsStateWithLifecycle() + FlipcashTheme { rememberQrBitmapPainter( content = stringResource( @@ -149,82 +158,40 @@ internal fun App( LocalSharedTransitionScope provides this, ) { CoinbaseOnRampHandler { - AppNavHost( - navigator = codeNavigator, - resultStateRegistry = resultStateRegistry, - decorators = listOf( - 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 }) + if (isNewUi) { + NewAppContent( + 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 + ) + ) + }, + onDismissed = { } + ) + else -> {} + } + deepLink = null } - }, - onBack = { codeNavigator.navigateBack() }, - entryProvider = appEntryProvider( + ) + } else { + AppContent( + codeNavigator = codeNavigator, resultStateRegistry = resultStateRegistry, barManager = barManager, deepLink = { deepLink }, @@ -251,9 +218,9 @@ internal fun App( else -> {} } deepLink = null - }, - ), - ) + } + ) + } ScrimOverlay(scrimController) } 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 new file mode 100644 index 000000000..407f5702e --- /dev/null +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/AppNavigationBar.kt @@ -0,0 +1,84 @@ +package com.flipcash.app.internal.ui + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flipcash.app.core.AppRoute +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.getcode.manager.BottomBarManager +import com.getcode.navigation.core.CodeNavigator +import com.getcode.theme.CodeTheme + +/** + * The hoisted v2 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. + * + * 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). + */ +@Composable +internal fun AppNavigationBar( + navigator: CodeNavigator, + modifier: Modifier = Modifier, +) { + // Selection follows the base of the backstack (the tab "home"), so it stays correct while a + // sheet/modal sits on top and is right on launch. The top route only gates visibility. + val selectedTab = navigator.backStack.firstNotNullOfOrNull { (it as? AppRoute)?.asNavBarTab() } + val topTab = (navigator.currentRouteKey as? AppRoute)?.asNavBarTab() + + // A BottomBar message (e.g. the deposit-options modal) renders above the nav host; hide the bar + // while one is showing so it sits below the modal instead of floating over it. + val bottomBarMessages by BottomBarManager.messages.collectAsStateWithLifecycle() + + Box( + modifier = Modifier + .then(modifier), + contentAlignment = Alignment.BottomCenter, + ) { + AnimatedVisibility( + visible = topTab != null && bottomBarMessages.isEmpty(), + enter = slideInVertically { it } + fadeIn(), + exit = slideOutVertically { it } + fadeOut(), + ) { + val state = rememberNavigationBarState( + isNewUi = true, + config = NavBarConfig(order = NavBarButton.v2Order), + selectedTab = selectedTab ?: NavBarButton.Wallet, + ) + NavigationBar( + modifier = Modifier + .navigationBarsPadding() + .padding(horizontal = CodeTheme.dimens.grid.x8) + .padding(bottom = CodeTheme.dimens.grid.x3), + state = state, + onButtonClick = { button -> + // Tab bar semantics: swap the current screen (single backstack). + button.destinationRoute()?.let(navigator::replaceAll) + }, + ) + } + } +} 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 new file mode 100644 index 000000000..d447d5b86 --- /dev/null +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppContent.kt @@ -0,0 +1,190 @@ +package com.flipcash.app.internal.ui.navigation + +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.togetherWith +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.navigation3.scene.OverlayScene +import androidx.navigation3.scene.SinglePaneSceneStrategy +import com.flipcash.app.core.AppRoute +import com.flipcash.app.core.navigation.DeeplinkAction +import com.flipcash.app.core.navigation.LocalTabBarPadding +import com.flipcash.app.internal.ui.AppNavigationBar +import com.flipcash.app.internal.ui.navigation.decorators.rememberNavBlockingOverlayEntryDecorator +import com.flipcash.app.internal.ui.navigation.decorators.rememberNavMessagingEntryDecorator +import com.getcode.navigation.AppNavHost +import com.getcode.navigation.core.CodeNavigator +import com.getcode.navigation.results.NavResultStateRegistry +import com.getcode.navigation.scenes.ModalBottomSheetSceneStrategy +import com.getcode.ui.components.bars.BarManager +import com.getcode.ui.theme.CodeScaffold +import dev.theolm.rinku.DeepLink + +@Composable +internal fun AppContent( + codeNavigator: CodeNavigator, + resultStateRegistry: NavResultStateRegistry, + barManager: BarManager, + deepLink: () -> DeepLink?, + onPendingAction: (DeeplinkAction) -> Unit = {}, +) { + AppNavHost( + navigator = codeNavigator, + resultStateRegistry = resultStateRegistry, + decorators = listOf( + 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, + deepLink: () -> DeepLink?, + onPendingAction: (DeeplinkAction) -> Unit = {}, +) { + CodeScaffold( + bottomBar = { + AppNavigationBar(navigator = codeNavigator) + } + ) { padding -> + CompositionLocalProvider(LocalTabBarPadding provides padding) { + AppNavHost( + navigator = codeNavigator, + resultStateRegistry = resultStateRegistry, + decorators = listOf( + rememberNavMessagingEntryDecorator( + codeNavigator.backStack, + barManager + ), + rememberNavBlockingOverlayEntryDecorator(), + ), + sceneStrategies = listOf( + ModalBottomSheetSceneStrategy( + codeNavigator.resultStore + ) { + codeNavigator.backStack.getOrNull( + codeNavigator.backStack.lastIndex - 1 + ) + }, + SinglePaneSceneStrategy(), + ), + // v2 is tab-centric: switching tabs (replaceAll) crossfades. Sheets/overlays keep + // their own (no) transition; everything else fades too. + transitionSpec = { + if (targetState is OverlayScene<*> || initialState is OverlayScene<*>) { + EnterTransition.None togetherWith ExitTransition.None + } else { + fadeIn(tween(300)) togetherWith fadeOut(tween(300)) + } + }, + popTransitionSpec = { + if (targetState is OverlayScene<*> || initialState is OverlayScene<*>) { + EnterTransition.None togetherWith ExitTransition.None + } else { + fadeIn(tween(300)) togetherWith fadeOut(tween(300)) + } + }, + predictivePopTransitionSpec = { + if (targetState is OverlayScene<*> || initialState is OverlayScene<*>) { + EnterTransition.None togetherWith ExitTransition.None + } else { + fadeIn(tween(300)) togetherWith fadeOut(tween(300)) + } + }, + onBack = { codeNavigator.navigateBack() }, + entryProvider = appEntryProvider( + isNewUi = true, + resultStateRegistry = resultStateRegistry, + barManager = barManager, + deepLink = deepLink, + onPendingAction = onPendingAction, + ), + ) + } + } +} \ No newline at end of file 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 15bc64f1d..a7c4f683d 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 @@ -23,6 +23,7 @@ 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 import com.flipcash.app.currencycreator.CurrencyCreatorFlowScreen @@ -67,6 +68,7 @@ import com.getcode.ui.components.bars.BarManager import dev.theolm.rinku.DeepLink fun appEntryProvider( + isNewUi: Boolean, resultStateRegistry: NavResultStateRegistry, barManager: BarManager, deepLink: () -> DeepLink?, @@ -74,7 +76,7 @@ fun appEntryProvider( ): (NavKey) -> NavEntry = entryProvider { // Loading / splash - annotatedEntry { MainRoot(deepLink, onPendingAction) } + annotatedEntry { MainRoot(isNewUi, deepLink, onPendingAction) } // Onboarding flow annotatedEntry { key -> @@ -83,7 +85,7 @@ fun appEntryProvider( // Main annotatedEntry { key -> - SheetContent(key, resultStateRegistry, barManager) + SheetContent(key, isNewUi, resultStateRegistry, barManager) } annotatedEntry { key -> AppRestrictedScreen(key.restrictionType) } annotatedEntry { ScannerScreen() } @@ -98,7 +100,13 @@ fun appEntryProvider( } annotatedEntry { key -> TokenSelectScreen(key.purpose) } annotatedEntry { TipAmountEntryScreen() } - annotatedEntry { BalanceScreen() } + annotatedEntry { + if (isNewUi) { + WalletScreen() + } else { + BalanceScreen() + } + } annotatedEntry { ShareAppScreen() } annotatedEntry { MenuScreen() } @@ -162,6 +170,7 @@ fun appEntryProvider( @Composable private fun SheetContent( key: AppRoute.Main.Sheet, + isNewUi: Boolean, resultStateRegistry: NavResultStateRegistry, barManager: BarManager, ) { @@ -243,7 +252,7 @@ private fun SheetContent( } }, onBack = { onBack() }, - entryProvider = appEntryProvider(resultStateRegistry, barManager, deepLink = { null }), + entryProvider = appEntryProvider(isNewUi, 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 babd5e52a..38d2ef4fb 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 @@ -27,6 +27,7 @@ import com.flipcash.app.core.AppRoute import com.flipcash.app.core.navigation.DeeplinkAction import com.flipcash.app.core.extensions.navigateAll import com.flipcash.app.core.extensions.resolveRoutes +import com.flipcash.app.featureflags.LocalFeatureFlags import com.flipcash.app.router.LocalRouter import com.flipcash.app.router.Router import com.flipcash.services.user.AuthState @@ -45,6 +46,7 @@ import kotlin.time.Duration.Companion.seconds @Composable internal fun MainRoot( + isNewUi: Boolean, deepLink: () -> DeepLink?, onPendingAction: (DeeplinkAction) -> Unit = {}, ) { @@ -102,6 +104,7 @@ internal fun MainRoot( ) val launch = buildNavGraphForLaunch( state = state, + isNewUi = isNewUi, router = router, deepLink = deepLink ) @@ -183,6 +186,7 @@ private fun List.startsWith(prefix: List): Boolean { internal fun buildNavGraphForLaunch( state: AuthState, router: Router, + isNewUi: Boolean, deepLink: () -> DeepLink?, ): LaunchNavGraph? { return when (state) { @@ -213,30 +217,31 @@ internal fun buildNavGraphForLaunch( } AuthState.Ready -> { + // New UI opens on the Wallet tab; v1 opens on the Scanner. + val home = if (isNewUi) AppRoute.Sheets.Wallet else AppRoute.Main.Scanner val link = deepLink() if (link != null) { when (val action = router.dispatch(link)) { is DeeplinkAction.Navigate -> LaunchNavGraph( - baseRoutes = listOf(AppRoute.Main.Scanner), + baseRoutes = listOf(home), deeplinkRoutes = action.routes, ) is DeeplinkAction.OpenCashLink, is DeeplinkAction.PresentTipCard, is DeeplinkAction.Login -> LaunchNavGraph( - baseRoutes = listOf(AppRoute.Main.Scanner), + baseRoutes = listOf(home), pendingAction = action, ) - else -> LaunchNavGraph(listOf(AppRoute.Main.Scanner)) + else -> LaunchNavGraph(listOf(home)) } } else { - LaunchNavGraph(listOf(AppRoute.Main.Scanner)) + LaunchNavGraph(listOf(home)) } } - AuthState.LoggedOut, - AuthState.Unknown -> { + AuthState.LoggedOut -> { val link = deepLink() if (link != null) { when (val action = router.dispatch(link)) { @@ -248,6 +253,10 @@ internal fun buildNavGraphForLaunch( } } + // Transient pre-resolution states — wait on the Loading screen. Navigating to login here + // (ClearAll) would tear down MainRoot's auth observer before Ready arrives, stranding an + // authenticated user on login. A genuine no-account resolves to LoggedOut (handled above). + AuthState.Unknown, AuthState.Authenticating -> null } } 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 5805f2e70..0b6ff5b23 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 @@ -99,6 +99,7 @@ sealed interface AppRoute : NavKey, Parcelable { @Serializable @Parcelize sealed interface Main : AppRoute { + @Serializable data class AppRestricted(val restrictionType: RestrictionType) : Main @Serializable @@ -133,6 +134,7 @@ sealed interface AppRoute : NavKey, Parcelable { ) : AppRoute, FlowRouteWithResult { override val initialStack: List get() = buildVerificationInitialStack( + forOnRamp = target is Token.Swap, includePhone = includePhone, includeEmail = includeEmail, emailAddress = email, @@ -306,14 +308,20 @@ sealed interface AppRoute : NavKey, Parcelable { } private fun buildVerificationInitialStack( + forOnRamp: Boolean, includePhone: Boolean, includeEmail: Boolean, emailAddress: String?, emailVerificationCode: String?, ): List { + if (includePhone && includeEmail) { + return listOf(VerificationStep.Intro(forOnRamp)) + } + if (includePhone) { return listOf(VerificationStep.PhoneEntry) } + if (includeEmail) { return buildList { add(VerificationStep.EmailEntry) diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/LocalTabBarPadding.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/LocalTabBarPadding.kt new file mode 100644 index 000000000..2341de816 --- /dev/null +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/LocalTabBarPadding.kt @@ -0,0 +1,6 @@ +package com.flipcash.app.core.navigation + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.runtime.staticCompositionLocalOf + +val LocalTabBarPadding = staticCompositionLocalOf { PaddingValues() } \ No newline at end of file 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 dcd3ee99d..3a8dae4d7 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 @@ -5,9 +5,13 @@ enum class NavBarButton { Wallet, Discover, Tips, + Chats, + TipCard, + Scanner, ; companion object { val defaultOrder = listOf(Discover, Give, Tips, Wallet,) + val v2Order = 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 index 0334daefc..88845b65c 100644 --- 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 @@ -1,5 +1,13 @@ 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, @@ -12,25 +20,26 @@ data class 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 { NavBarButton.defaultOrder } - ?: NavBarButton.defaultOrder + ?.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(NavBarButton.defaultOrder)) { + val order = if (stored.containsAll(default)) { stored } else { - NavBarButton.defaultOrder.fold(stored) { acc, button -> + default.fold(stored) { acc, button -> if (button in acc) { acc } else { - val insertAt = NavBarButton.defaultOrder - .subList(0, NavBarButton.defaultOrder.indexOf(button)) + val insertAt = default + .subList(0, default.indexOf(button)) .let { preceding -> acc.indexOfLast { it in preceding } + 1 } acc.toMutableList().apply { add(insertAt, button) } } 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 new file mode 100644 index 000000000..373e84b9d --- /dev/null +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/NavBarRoutes.kt @@ -0,0 +1,25 @@ +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 + * 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) { + NavBarButton.Scanner -> AppRoute.Main.Scanner + NavBarButton.Wallet -> AppRoute.Sheets.Wallet + NavBarButton.Chats -> null // TODO(v2): wire the chats destination + NavBarButton.TipCard -> null // TODO(v2): wire the tip-card destination + // 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. */ +fun AppRoute.asNavBarTab(): NavBarButton? = when (this) { + AppRoute.Main.Scanner -> NavBarButton.Scanner + AppRoute.Sheets.Wallet -> NavBarButton.Wallet + else -> null +} diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/ui/NavigationBar.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/ui/NavigationBar.kt index 419001637..e491718d6 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/ui/NavigationBar.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/ui/NavigationBar.kt @@ -1,7 +1,11 @@ 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 @@ -9,24 +13,34 @@ 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.clickable 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 +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.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 @@ -44,8 +58,10 @@ 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 com.flipcash.app.core.navigation.NavBarConfig @@ -60,27 +76,64 @@ import com.getcode.ui.utils.heightOrZero import com.getcode.ui.utils.widthOrZero data class NavigationBarState( - val contactDmUnreadCount: Int = 0, + 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, ) +@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, + ) { + value = NavigationBarState( + isNewUi = isNewUi, + config = config, + selectedTab = selectedTab, + tipUnreadCount = tipUnreadCount, + showToast = showToast, + toastText = toastText, + isPaused = isPaused, + ) + }.value +} + @Composable fun NavigationBar( modifier: Modifier = Modifier, - config: NavBarConfig = NavBarConfig.Default, - state: NavigationBarState = NavigationBarState(), + state: NavigationBarState, onButtonClick: (NavBarButton) -> Unit = {}, onOrderChanged: ((List) -> Unit)? = null, ) { val reorderState = onOrderChanged?.let { rememberLongPressDraggableState( - itemCount = config.order.size, - key = config.order, + itemCount = state.config.order.size, + key = state.config.order, onReorder = { from, to -> - val newOrder = config.order.toMutableList() + val newOrder = state.config.order.toMutableList() val item = newOrder.removeAt(from) newOrder.add(to, item) onOrderChanged(newOrder) @@ -88,6 +141,11 @@ fun NavigationBar( ) } + if (state.isNewUi) { + NavigationBarV2(state, onButtonClick, modifier, reorderState, onOrderChanged) + return + } + Row( modifier = Modifier .fillMaxWidth() @@ -95,10 +153,12 @@ fun NavigationBar( verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.SpaceAround, ) { - val imageSize by animateDpAsState(if (config.order.size < 5) CodeTheme.dimens.staticGrid.x10 else CodeTheme.dimens.staticGrid.x7) - config.order.forEachIndexed { index, button -> + 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) + Modifier + .weight(1f) + .longPressDraggable(reorderState, index) } else { Modifier.weight(1f) } @@ -106,12 +166,13 @@ fun NavigationBar( when (button) { NavBarButton.Give -> BottomBarAction( modifier = buttonModifier, - label = stringResource(config.giveButtonLabel.labelRes), + 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), @@ -121,10 +182,14 @@ fun NavigationBar( toast = { AnimatedVisibility( visible = state.showToast && state.toastText != null, - enter = slideInVertically(animationSpec = tween(600), initialOffsetY = { it }) + + enter = slideInVertically( + animationSpec = tween(600), + initialOffsetY = { it }) + fadeIn(animationSpec = tween(500, 100)), exit = if (!state.isPaused) - slideOutVertically(animationSpec = tween(600), targetOffsetY = { it }) + + slideOutVertically( + animationSpec = tween(600), + targetOffsetY = { it }) + fadeOut(animationSpec = tween(500, 100)) else fadeOut(animationSpec = tween(0)), ) { @@ -138,6 +203,7 @@ fun NavigationBar( } } ) + NavBarButton.Discover -> BottomBarAction( modifier = buttonModifier, label = stringResource(R.string.action_discover), @@ -155,11 +221,96 @@ fun NavigationBar( 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, +) { + val order = state.config.order + 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) + + BoxWithConstraints( + modifier = Modifier + .fillMaxWidth() + .then(modifier) + .background(Color.Black.copy(alpha = 0.62f), CircleShape) + .padding(CodeTheme.dimens.grid.x1), + ) { + val itemWidth = maxWidth / order.size + + // Selected-state pill that slides to the active tab, drawn behind the icons. + val indicatorOffset by animateDpAsState( + targetValue = itemWidth * selectedIndex, + animationSpec = spring(stiffness = Spring.StiffnessMediumLow), + label = "navBarIndicatorOffset", + ) + Box( + modifier = Modifier + .offset { IntOffset(indicatorOffset.roundToPx(), 0) } + .width(itemWidth) + .height(itemHeight) + .background(Color.White.copy(alpha = 0.2f), CircleShape), + ) + + Row(modifier = Modifier.fillMaxWidth()) { + order.fastForEach { button -> + val selected = button == state.selectedTab + val iconAlpha by animateFloatAsState( + targetValue = if (selected) 1f else 0.5f, + label = "navBarIconAlpha", + ) + Box( + modifier = Modifier + .weight(1f) + .height(itemHeight) + .clip(CircleShape) + .clickable { onButtonClick(button) }, + contentAlignment = Alignment.Center, + ) { + Image( + modifier = Modifier + .size(iconSize) + .graphicsLayer { alpha = iconAlpha }, + painter = painterResource(button.icon), + colorFilter = ColorFilter.tint(Color.White), + contentDescription = null, + ) + } } } } } +@get:DrawableRes +private val NavBarButton.icon: Int + get() = when (this) { + NavBarButton.Scanner -> R.drawable.ic_nav_scan + 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, @@ -308,6 +459,24 @@ private fun BottomBarAction( @Composable private fun NavigationBarPreview() { NavigationBar( - state = NavigationBarState(contactDmUnreadCount = 100), + 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 + ), + onButtonClick = { } ) } diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/ui/TokenCard.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/ui/TokenCard.kt new file mode 100644 index 000000000..91af39bad --- /dev/null +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/ui/TokenCard.kt @@ -0,0 +1,165 @@ +package com.flipcash.app.core.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +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.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.flipcash.app.core.money.formattedAppreciation +import com.getcode.opencode.model.financial.LocalFiat +import com.getcode.opencode.model.financial.Token +import com.getcode.opencode.model.financial.TokenWithLocalizedBalance +import com.getcode.opencode.model.ui.BillBackground +import com.getcode.opencode.model.ui.TokenBillCustomizations +import com.getcode.solana.keys.Mint +import com.getcode.theme.CodeTheme +import com.getcode.theme.tiny +import com.getcode.ui.components.text.AnimatedNumberText +import com.getcode.ui.core.addIf +import com.getcode.ui.utils.ConstraintMode +import com.getcode.ui.utils.hexToColor + +/** + * A reusable bill-style card for a single token (Figma frame 8966:99811). + * + * The background is painted from the token's **bill-customization colors** + * ([Token.billCustomizations] → [BillBackground]) as a horizontal gradient — the same palette users + * pick in the currency creator — falling back to the bill's dark-green when a token has no + * customization. Header shows the token icon + name (top-left) and the balance + optional + * appreciation pill (top-right). + * + * These are designed to stack: render several in a Column with a negative `verticalArrangement` + * spacing (~ -160.dp) so only each card's ~64dp header shows, like the Figma frame. + */ +@Composable +fun TokenCard( + tokenWithBalance: TokenWithLocalizedBalance, + modifier: Modifier = Modifier, + height: Dp = 224.dp, + onClick: (() -> Unit)? = null, +) { + val (token, balance, appreciation, displayName) = tokenWithBalance + TokenCard( + token = token, + balanceText = balance.nativeAmount.formatted(), + modifier = modifier, + displayName = displayName, + appreciationText = appreciation + .takeIf { it != LocalFiat.MIN_VALUE }?.nativeAmount + ?.formattedAppreciation(), + height = height, + onClick = onClick, + ) +} + +@Composable +fun TokenCard( + token: Token, + balanceText: String, + modifier: Modifier = Modifier, + displayName: String = token.name, + appreciationText: String? = null, + height: Dp = 224.dp, + onClick: (() -> Unit)? = null, +) { + val isUsdf = token.address == Mint.usdf + val shape = CodeTheme.shapes.medium + val brush = remember(token.billCustomizations, isUsdf) { + if (isUsdf) UsdfBrush else billCardBrush(token.billCustomizations) + } + + Box( + modifier = modifier + .fillMaxWidth() + .height(height) + .clip(shape) + .background(brush) + .border(CodeTheme.dimens.border, CodeTheme.colors.surfaceVariant, shape) + .addIf(onClick != null) { Modifier.clickable { onClick?.invoke() } } + .padding(CodeTheme.dimens.inset), + ) { + // Header: token icon + name (left) and appreciation pill + balance (right). Name and the + // right cluster split the row via weights so a long balance autosizes down within its half + // instead of colliding with the name. + Row( + modifier = Modifier + .align(Alignment.TopCenter) + .fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), + verticalAlignment = Alignment.CenterVertically, + ) { + TokenIconWithName( + tokenName = displayName, + tokenImage = token.imageUrl, + imageSize = 24.dp, + textStyle = CodeTheme.typography.textSmall, + textColor = CodeTheme.colors.textMain, + spacing = CodeTheme.dimens.grid.x1, + ) + + Row( + modifier = Modifier.weight(1f), + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1, Alignment.End), + verticalAlignment = Alignment.CenterVertically, + ) { + appreciationText?.let { + Text( + modifier = Modifier + .border(CodeTheme.dimens.border, Color.White, CodeTheme.shapes.tiny) + .padding(horizontal = CodeTheme.dimens.grid.x1, vertical = CodeTheme.dimens.grid.x1 - 1.dp), + text = it, + style = CodeTheme.typography.caption, + color = Color.White, + ) + } + AnimatedNumberText( + value = balanceText, + modifier = Modifier.weight(1f, fill = false), + style = CodeTheme.typography.screenTitle.copy(fontWeight = FontWeight.Bold), + color = Color.White, + constraintMode = ConstraintMode.AutoSize(minimum = CodeTheme.typography.textMedium), + ) + } + } + } +} + +/** USDF's fixed gold branding (Figma) — used instead of a user-chosen bill color. */ +private val UsdfBrush = Brush.horizontalGradient(listOf(Color(0xFFC4980B), Color(0xFFB06B00))) + +/** Horizontal gradient brush from a token's bill-customization colors (matches the Figma cards). */ +private fun billCardBrush(customizations: TokenBillCustomizations?): Brush { + val fallback = Color(0xFF06450F) // matches CashBill's no-customization fallback + return when (val background = customizations?.background) { + null -> Brush.horizontalGradient(listOf(fallback, fallback)) + is BillBackground.Solid -> { + val color = hexToColor(background.colorHex) + Brush.horizontalGradient(listOf(color, color)) + } + is BillBackground.Gradient -> { + val colors = background.colors.map { hexToColor(it) } + val lastIndex = (colors.size - 1).coerceAtLeast(1) + val colorStops = colors.mapIndexed { index, color -> + (index.toFloat() / lastIndex) to color + }.toTypedArray() + + Brush.horizontalGradient(colorStops = colorStops,) + } + } +} \ No newline at end of file diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/ui/TokenCardStack.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/ui/TokenCardStack.kt new file mode 100644 index 000000000..a7aed893f --- /dev/null +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/ui/TokenCardStack.kt @@ -0,0 +1,66 @@ +package com.flipcash.app.core.ui + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.getcode.opencode.model.financial.TokenWithLocalizedBalance +import kotlin.collections.forEach + +/** + * A vertical stack of [TokenCard]s that fans out (each card revealing its [fannedReveal] header) and + * **sticks per-card** on scroll: as a card scrolls above the viewport top it pins into a growing + * deck at the top ([collapsedReveal] per pinned card) while the cards below stay fanned and readable. + * + * The stack's measured height is always the *fanned* height, so the enclosing list scrolls stably + * (cards are only repositioned, never resized — no feedback into the scroll range). Each card sits + * at `max(fannedY, pinnedY)`, a continuous transition from fanned to pinned with no jump. + * + * [scrolledPast] = how many px the stack's top has scrolled above the viewport top (`-itemOffset`); + * a lambda so the layout re-reads it on scroll without recomposing the whole stack. Cards are drawn + * front-to-back so the last (highest-value) card sits on top. + */ +@Composable +fun TokenCardStack( + tokens: List, + modifier: Modifier = Modifier, + cardHeight: Dp = 224.dp, + fannedReveal: Dp = 64.dp, + collapsedReveal: Dp = 12.dp, + pinInset: Dp = 0.dp, + scrolledPast: () -> Float = { 0f }, + onCardClick: (TokenWithLocalizedBalance) -> Unit = {}, +) { + Layout( + modifier = modifier.fillMaxWidth(), + content = { + tokens.forEach { token -> + TokenCard( + tokenWithBalance = token, + height = cardHeight, + onClick = { onCardClick(token) }, + ) + } + }, + ) { measurables, constraints -> + val fannedPx = fannedReveal.roundToPx() + val collapsedPx = collapsedReveal.roundToPx() + // [pinInset] holds the pinned deck below any top chrome (e.g. the status bar) once cards stick. + val pinInsetPx = pinInset.roundToPx() + // Not clamped to ≥0: a negative value (stack below the pin line) keeps cards fanned flush. + val past = scrolledPast() + val placeables = measurables.map { it.measure(constraints.copy(minHeight = 0)) } + val cardPx = placeables.firstOrNull()?.height ?: 0 + // Always the fanned height, so the list scroll range is stable while cards pin. + val height = if (placeables.isEmpty()) 0 else cardPx + fannedPx * (placeables.size - 1) + layout(constraints.maxWidth, height) { + placeables.forEachIndexed { index, placeable -> + val fannedY = index * fannedPx + val pinnedY = (past + pinInsetPx + index * collapsedPx).toInt() + placeable.placeRelative(0, maxOf(fannedY, pinnedY)) + } + } + } +} \ No newline at end of file diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/verification/VerificationStep.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/verification/VerificationStep.kt index 733fc8c62..76e85dd9e 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/verification/VerificationStep.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/verification/VerificationStep.kt @@ -11,6 +11,10 @@ import kotlinx.serialization.Serializable */ @Serializable sealed interface VerificationStep : FlowStep, Parcelable { + @Parcelize + @Serializable + data class Intro(val isForOnRamp: Boolean): VerificationStep + @Parcelize @Serializable data object PhoneEntry : VerificationStep diff --git a/apps/flipcash/core/src/main/res/drawable/ic_nav_chat.xml b/apps/flipcash/core/src/main/res/drawable/ic_nav_chat.xml new file mode 100644 index 000000000..bd5123f02 --- /dev/null +++ b/apps/flipcash/core/src/main/res/drawable/ic_nav_chat.xml @@ -0,0 +1,19 @@ + + + + + diff --git a/apps/flipcash/core/src/main/res/drawable/ic_nav_scan.xml b/apps/flipcash/core/src/main/res/drawable/ic_nav_scan.xml new file mode 100644 index 000000000..592429947 --- /dev/null +++ b/apps/flipcash/core/src/main/res/drawable/ic_nav_scan.xml @@ -0,0 +1,25 @@ + + + + + + diff --git a/apps/flipcash/core/src/main/res/drawable/ic_nav_tipcard.xml b/apps/flipcash/core/src/main/res/drawable/ic_nav_tipcard.xml new file mode 100644 index 000000000..2ab71f9c5 --- /dev/null +++ b/apps/flipcash/core/src/main/res/drawable/ic_nav_tipcard.xml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + diff --git a/apps/flipcash/core/src/main/res/drawable/ic_nav_wallet.xml b/apps/flipcash/core/src/main/res/drawable/ic_nav_wallet.xml new file mode 100644 index 000000000..8e79a5746 --- /dev/null +++ b/apps/flipcash/core/src/main/res/drawable/ic_nav_wallet.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 8f7656e9c..cfa76f462 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -527,6 +527,10 @@ Buy More Add Money + Add money to your account + + Scan a Tip Card + Give your first tip Amount to Buy Amount to Sell @@ -753,6 +757,7 @@ Solana USDC USDC USDF + Dollars Withdrawal amount Less fee Net amount @@ -920,5 +925,6 @@ Something Went Wrong We were unable to unblock the user. Please try again + Send Your First Tip \ No newline at end of file diff --git a/apps/flipcash/features/balance/build.gradle.kts b/apps/flipcash/features/balance/build.gradle.kts index c3a9a99b4..b106c5686 100644 --- a/apps/flipcash/features/balance/build.gradle.kts +++ b/apps/flipcash/features/balance/build.gradle.kts @@ -13,7 +13,9 @@ dependencies { implementation(libs.compose.paging) + implementation(project(":apps:flipcash:shared:activityfeed")) implementation(project(":apps:flipcash:shared:analytics")) + implementation(project(":apps:flipcash:shared:chat")) implementation(project(":apps:flipcash:shared:featureflags")) implementation(project(":apps:flipcash:shared:funding")) implementation(project(":apps:flipcash:shared:tokens")) diff --git a/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/WalletScreen.kt b/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/WalletScreen.kt new file mode 100644 index 000000000..0c401bd0b --- /dev/null +++ b/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/WalletScreen.kt @@ -0,0 +1,57 @@ +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.hilt.lifecycle.viewmodel.compose.hiltViewModel +import com.flipcash.app.balance.internal.BalanceViewModel +import com.flipcash.app.balance.internal.WalletScreen +import com.flipcash.app.core.AppRoute +import com.flipcash.app.core.extensions.openAsSheet +import com.flipcash.app.core.tokens.TokenPurpose +import com.flipcash.app.tokens.ui.SelectTokenViewModel +import com.getcode.navigation.core.LocalCodeNavigator +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach + +@Composable +fun WalletScreen() { + val navigator = LocalCodeNavigator.current + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + val viewModel = hiltViewModel() + val tokenViewModel = hiltViewModel() + WalletScreen(viewModel, tokenViewModel) + + LaunchedEffect(tokenViewModel) { + tokenViewModel.dispatchEvent( + SelectTokenViewModel.Event.OnPurposeChanged( + TokenPurpose.Balance + ) + ) + } + + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .onEach { + navigator.openAsSheet(AppRoute.Main.RegionSelection) + }.launchIn(this) + } + + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .map { it.screen } + .onEach { navigator.openAsSheet(it) } + .launchIn(this) + } + } +} \ No newline at end of file diff --git a/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/BalanceViewModel.kt b/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/BalanceViewModel.kt index 86955c075..c10d5d029 100644 --- a/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/BalanceViewModel.kt +++ b/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/BalanceViewModel.kt @@ -3,15 +3,20 @@ package com.flipcash.app.balance.internal import androidx.lifecycle.viewModelScope import com.flipcash.app.analytics.Analytics import com.flipcash.app.analytics.FlipcashAnalyticsService +import com.flipcash.app.balance.internal.components.OnboardingItem import com.flipcash.app.core.AppRoute +import com.flipcash.app.activityfeed.ActivityFeedCoordinator import com.flipcash.app.funding.PurchaseMethodController import com.flipcash.app.userflags.UserFlagsCoordinator +import com.flipcash.shared.chat.ChatCoordinator import com.flipcash.services.internal.model.thirdparty.OnRampProvider import com.flipcash.services.user.AuthState import com.flipcash.services.user.UserManager import com.flipcash.libs.coroutines.DispatcherProvider +import com.getcode.opencode.utils.combine import com.getcode.view.BaseViewModel import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.flatMapLatest @@ -28,6 +33,8 @@ internal class BalanceViewModel @Inject constructor( dispatchers: DispatcherProvider, purchaseMethodController: PurchaseMethodController, analytics: FlipcashAnalyticsService, + chatCoordinator: ChatCoordinator, + feedCoordinator: ActivityFeedCoordinator, ) : BaseViewModel( initialState = State(), updateStateForEvent = updateStateForEvent, @@ -35,9 +42,17 @@ internal class BalanceViewModel @Inject constructor( ) { data class State( val preferredOnRampProvider: OnRampProvider.Defined? = null, - ) + val onboardingItems: List = emptyList(), + ) { + val hasAddedMoney: Boolean + get() = onboardingItems.find { it is OnboardingItem.AddMoney }?.isCompleted == true + + val isOnboardingComplete: Boolean + get() = onboardingItems.all { it.isCompleted } + } sealed interface Event { + data class OnOnboardingItemsUpdated(val items: List): Event data class OnPreferredOnRampProviderChanged(val provider: OnRampProvider.Defined?) : Event data object OpenCurrencySelection : Event @@ -61,6 +76,21 @@ internal class BalanceViewModel @Inject constructor( purchaseMethodController.presentDepositOptions(popToRoot = true) } .onEach { route -> dispatchEvent(Event.OpenScreen(route)) } .launchIn(viewModelScope) + + // Onboarding funnel milestones, derived from durable event history (not current balance): + // "added money" = a completed deposit/buy in the activity feed; "scanned a tip card" = + // a Cash chat message with verb TIPPED. + combine( + feedCoordinator.hasEverAddedMoney(), + chatCoordinator.hasEverTipped(), + ) { hasAddedMoney, hasTipped -> + listOf( + OnboardingItem.AddMoney(isCompleted = hasAddedMoney), + OnboardingItem.ScanTipCard(isCompleted = hasTipped), + ) + } + .onEach { items -> dispatchEvent(Event.OnOnboardingItemsUpdated(items)) } + .launchIn(viewModelScope) } internal companion object { @@ -70,6 +100,9 @@ internal class BalanceViewModel @Inject constructor( is Event.OnPreferredOnRampProviderChanged -> { state -> state.copy(preferredOnRampProvider = event.provider) } + is Event.OnOnboardingItemsUpdated -> { state -> + state.copy(onboardingItems = event.items) + } Event.PresentDepositOptions -> { state -> state } is Event.OpenScreen -> { state -> state } } 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 new file mode 100644 index 000000000..652b182ad --- /dev/null +++ b/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/WalletScreenContent.kt @@ -0,0 +1,177 @@ +package com.flipcash.app.balance.internal + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.Icon +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.AddCircleOutline +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.vector.rememberVectorPainter +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.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flipcash.app.core.AppRoute +import com.flipcash.app.core.ui.TokenCardStack +import com.flipcash.app.balance.internal.components.BalanceHeader +import com.flipcash.app.balance.internal.components.OnboardingFunnel +import com.flipcash.app.balance.internal.components.OnboardingItem +import com.flipcash.app.core.navigation.LocalTabBarPadding +import com.flipcash.app.tokens.ui.SelectTokenViewModel +import com.flipcash.features.balance.R +import com.getcode.theme.CodeTheme + +private const val TokenStackKey = "tokenStack" + +@Composable +internal fun WalletScreen( + viewModel: BalanceViewModel, + tokenViewModel: SelectTokenViewModel, +) { + val balanceState by viewModel.stateFlow.collectAsStateWithLifecycle() + val tokenState by tokenViewModel.stateFlow.collectAsStateWithLifecycle() + WalletScreenContent( + balanceState = balanceState, + tokenState = tokenState, + dispatchEvent = viewModel::dispatchEvent + ) +} + +@Composable +internal fun WalletScreenContent( + balanceState: BalanceViewModel.State, + tokenState: SelectTokenViewModel.State, + dispatchEvent: (BalanceViewModel.Event) -> Unit +) { + val listState = rememberLazyListState() + // Sticky per-card collapse: the fan scrolls normally (the stack keeps a fixed fanned height, so + // scrolling is stable) and each card pins to the top as it scrolls above the viewport, building a + // deck while the cards below stay fanned and readable. `scrolledPast` = px of the stack scrolled + // above the viewport top, read live so the stack re-lays-out its cards as the list scrolls. + // May be negative when the stack sits below the pin line (i.e. scrolled to the top) so cards + // fan flush there instead of staying stuck under the pin inset. + val scrolledPast = { + listState.layoutInfo.visibleItemsInfo.firstOrNull { it.key == TokenStackKey } + ?.let { -it.offset.toFloat() } ?: 0f + } + val statusBarInset = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues( + start = CodeTheme.dimens.inset, + end = CodeTheme.dimens.inset, + bottom = LocalTabBarPadding.current.calculateBottomPadding(), + ) + ) { + item { + Spacer(Modifier.height(CodeTheme.dimens.grid.x20)) + } + + item { + BalanceHeader( + modifier = Modifier + .fillMaxWidth(), + balance = tokenState.totalBalance, + appreciation = tokenState.aggregateAppreciation, + ) { + dispatchEvent(BalanceViewModel.Event.OpenCurrencySelection) + } + } + + item { + Spacer(Modifier.height(CodeTheme.dimens.grid.x6)) + } + + if (!balanceState.isOnboardingComplete) { + item { + OnboardingFunnel( + modifier = Modifier.fillMaxWidth() + .padding(bottom = CodeTheme.dimens.grid.x5), + title = stringResource(R.string.title_tipOnboarding), + items = balanceState.onboardingItems, + ) { item -> + when (item) { + is OnboardingItem.AddMoney -> { + dispatchEvent(BalanceViewModel.Event.PresentDepositOptions) + } + is OnboardingItem.ScanTipCard -> { + + } + } + } + } + } + + tokenState.tokens?.takeIf { it.isNotEmpty() }?.let { tokens -> + item(key = TokenStackKey) { + TokenCardStack( + tokens = tokens, + modifier = Modifier.fillMaxWidth(), + pinInset = statusBarInset, + scrolledPast = scrolledPast, + onCardClick = { token -> + dispatchEvent( + BalanceViewModel.Event.OpenScreen( + AppRoute.Token.Info(mint = token.token.address) + ) + ) + }, + ) + } + } + + if (balanceState.hasAddedMoney) { + item { + Box( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = { dispatchEvent(BalanceViewModel.Event.PresentDepositOptions) }) + .padding(vertical = CodeTheme.dimens.inset), + contentAlignment = Alignment.Center, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1), + ) { + Icon( + painter = rememberVectorPainter(Icons.Outlined.AddCircleOutline), + contentDescription = null, + tint = CodeTheme.colors.textSecondary, + ) + Text( + text = stringResource(R.string.action_addMoney), + style = CodeTheme.typography.textMedium, + color = CodeTheme.colors.textSecondary, + ) + } + } + } + } + } +} \ No newline at end of file diff --git a/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/components/OnboardingFunnel.kt b/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/components/OnboardingFunnel.kt new file mode 100644 index 000000000..cb994e8dc --- /dev/null +++ b/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/components/OnboardingFunnel.kt @@ -0,0 +1,155 @@ +package com.flipcash.app.balance.internal.components + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.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.material.icons.Icons +import androidx.compose.material.icons.outlined.AddCircleOutline +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastForEach +import com.flipcash.features.balance.R +import com.getcode.theme.CodeTheme + +sealed interface OnboardingItem { + val title: String + @Composable get + val description: String + @Composable get + val icon: Painter + @Composable get + val isCompleted: Boolean + + class AddMoney(override val isCompleted: Boolean) : OnboardingItem { + override val title: String + @Composable get() = stringResource(R.string.title_addMoney) + override val description: String + @Composable get() = stringResource(R.string.subtitle_addMoney) + override val icon: Painter + @Composable get() = rememberVectorPainter(Icons.Outlined.AddCircleOutline) + + } + + class ScanTipCard(override val isCompleted: Boolean) : OnboardingItem { + override val title: String + @Composable get() = stringResource(R.string.title_scanTipCard) + override val description: String + @Composable get() = stringResource(R.string.subtitle_scanTipCard) + override val icon: Painter + @Composable get() = painterResource(R.drawable.ic_nav_scan) + } +} + +@Composable +fun OnboardingFunnel( + title: String, + items: List, + modifier: Modifier = Modifier, + onItemClicked: (OnboardingItem) -> Unit, +) { + val completedCount = remember(items) { items.count { it.isCompleted } } + + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.inset) + ) { + Row( + modifier = Modifier.fillMaxWidth() + .padding(horizontal = CodeTheme.dimens.inset), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = title, + style = CodeTheme.typography.screenTitle, + color = CodeTheme.colors.textMain, + ) + + Text( + text = "$completedCount / ${items.count()}", + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + ) + } + + Column( + modifier = Modifier + .clip(CodeTheme.shapes.medium) + .background(color = Color.White.copy(0.05f)), + ) { + items.fastForEach { item -> + OnboardingItemRow( + item = item, + modifier = Modifier.fillMaxWidth(), + ) { + onItemClicked(item) + } + } + } + } +} + +@Composable +private fun OnboardingItemRow( + item: OnboardingItem, + modifier: Modifier = Modifier, + onClick: () -> Unit, +) { + Row(modifier = modifier + .clickable(enabled = !item.isCompleted, onClick = onClick) + .padding(CodeTheme.dimens.inset), + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), + ) { + Image( + modifier = Modifier.align(Alignment.Top).size(24.dp), + painter = if (item.isCompleted) { + painterResource(R.drawable.ic_checked_green) + } else { + item.icon + }, + colorFilter = if (!item.isCompleted) ColorFilter.tint(CodeTheme.colors.textMain) else null, + contentDescription = null, + ) + Column( + modifier = Modifier + .weight(1f) + .alpha(if (item.isCompleted) 0.38f else 1f) + ) { + Text( + text = item.title, + style = CodeTheme.typography.textMedium, + color = CodeTheme.colors.textMain, + ) + Text( + text = item.description, + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + ) + } + Icon( + modifier = Modifier.align(Alignment.CenterVertically), + painter = painterResource(R.drawable.ic_chevron_right), + tint = CodeTheme.colors.textSecondary, + contentDescription = null + ) + } +} \ No newline at end of file diff --git a/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/VerificationFlowScreen.kt b/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/VerificationFlowScreen.kt index f48cb728d..928e0124b 100644 --- a/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/VerificationFlowScreen.kt +++ b/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/VerificationFlowScreen.kt @@ -6,6 +6,7 @@ import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.entryProvider import com.flipcash.app.contact.verification.email.EmailMagicLinkContent import com.flipcash.app.contact.verification.email.EmailVerificationContent +import com.flipcash.app.contact.verification.internal.VerificationIntroScreen import com.flipcash.app.contact.verification.phone.PhoneCodeContent import com.flipcash.app.contact.verification.phone.PhoneCountryCodeContent import com.flipcash.app.contact.verification.phone.PhoneVerificationContent @@ -58,6 +59,9 @@ fun VerificationFlowScreen( private fun verificationEntryProvider( route: AppRoute.Verification, ): (NavKey) -> NavEntry = entryProvider { + annotatedEntry { step -> + VerificationIntroScreen(step.isForOnRamp) + } annotatedEntry { PhoneVerificationContent() } diff --git a/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/internal/VerificationIntroScreen.kt b/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/internal/VerificationIntroScreen.kt new file mode 100644 index 000000000..b08555e29 --- /dev/null +++ b/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/internal/VerificationIntroScreen.kt @@ -0,0 +1,150 @@ +package com.flipcash.app.contact.verification.internal + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Alignment.Companion.Center +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.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewWrapper +import com.flipcash.app.analytics.Analytics +import com.flipcash.app.analytics.rememberAnalytics +import com.flipcash.app.core.verification.VerificationResult +import com.flipcash.app.core.verification.VerificationStep +import com.flipcash.app.theme.FlipcashThemeWrapper +import com.flipcash.features.contact.verification.R +import com.getcode.navigation.core.LocalCodeNavigator +import com.getcode.navigation.flow.rememberFlowNavigator +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 + +@Composable +fun VerificationIntroScreen( + isForOnRamp: Boolean = true, +) { + val flowNavigator = rememberFlowNavigator() + + VerificationIntroScreenContent( + isForOnRamp = isForOnRamp, + // The Intro only appears when both phone and email are requested (see + // buildVerificationInitialStack), which seeds just [Intro]. This flow is non-linear, so + // proceed() is a no-op — advance to the first real step (phone) explicitly. + onClick = { flowNavigator.navigateTo(VerificationStep.PhoneEntry) }, + ) + + val analytics = rememberAnalytics() + LaunchedEffect(Unit) { + analytics.onrampVerification(Analytics.OnrampVerificationStep.ShowInfo) + } +} + +@Composable +private fun VerificationIntroScreenContent( + isForOnRamp: Boolean, + onClick: () -> Unit, +) { + val navigator = LocalCodeNavigator.current + val isSheetRoot = remember { navigator.backStack.size <= 1 } + CodeScaffold( + modifier = Modifier + .fillMaxSize() + .windowInsetsPadding(WindowInsets.navigationBars), + topBar = { + if (isSheetRoot) { + AppBarWithTitle( + endContent = { + AppBarDefaults.Close { navigator.hide() } + }, + ) + } else { + AppBarWithTitle( + onBackIconClicked = { navigator.pop() }, + ) + } + }, + bottomBar = { + CodeButton( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = CodeTheme.dimens.inset) + .padding(bottom = CodeTheme.dimens.grid.x3) + .imePadding(), + buttonState = ButtonState.Filled, + text = stringResource(R.string.action_next), + ) { onClick() } + } + ) { innerPadding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + contentAlignment = Center + ) { + Column( + modifier = Modifier + .fillMaxWidth(0.8f), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(Modifier.weight(1f)) + Image( + painter = painterResource(R.drawable.ic_contact_method_verification), + contentDescription = null + ) + Text( + modifier = Modifier + .padding(top = CodeTheme.dimens.inset), + text = stringResource(R.string.title_verificationFlow), + style = CodeTheme.typography.textLarge, + color = CodeTheme.colors.textMain, + textAlign = TextAlign.Center, + ) + + if (isForOnRamp) { + Text( + modifier = Modifier + .padding(top = CodeTheme.dimens.grid.x3) + .padding(horizontal = CodeTheme.dimens.inset), + text = stringResource(R.string.subtitle_verificationFlowForOnramp), + style = CodeTheme.typography.textMedium, + color = CodeTheme.colors.textSecondary, + textAlign = TextAlign.Center, + ) + } + Spacer(Modifier.weight(1f)) + } + } + } +} + +@Preview +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +private fun Preview_FlowIntro() { + Box(modifier = Modifier.fillMaxSize().background(CodeTheme.colors.background)) { + VerificationIntroScreenContent( + isForOnRamp = true, + onClick = { } + ) + } +} diff --git a/apps/flipcash/features/home/.gitignore b/apps/flipcash/features/home/.gitignore new file mode 100644 index 000000000..9f2a07880 --- /dev/null +++ b/apps/flipcash/features/home/.gitignore @@ -0,0 +1,2 @@ +build/ +.gradle/ diff --git a/apps/flipcash/features/home/build.gradle.kts b/apps/flipcash/features/home/build.gradle.kts new file mode 100644 index 000000000..5a74d82c7 --- /dev/null +++ b/apps/flipcash/features/home/build.gradle.kts @@ -0,0 +1,21 @@ +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 new file mode 100644 index 000000000..205f61494 --- /dev/null +++ b/apps/flipcash/features/home/src/main/kotlin/com/flipcash/app/home/HomeScreen.kt @@ -0,0 +1,47 @@ +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/internal/NavBarSettingsContent.kt b/apps/flipcash/features/lab/src/main/kotlin/com/flipcash/app/lab/internal/NavBarSettingsContent.kt index a334f33ab..6630ed983 100644 --- 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 @@ -19,6 +19,7 @@ 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 @@ -56,7 +57,8 @@ internal fun NavBarSettingsContent() { contentAlignment = Alignment.Center, ) { NavigationBar( - config = config, + // 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()) 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 901a168e0..5dcf6e939 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 @@ -174,7 +174,6 @@ internal fun ScannableContainer( PermissionResult.NotRequested -> { CameraPermissionsMissingView( modifier = Modifier.fillMaxSize(), - backgroundColor = Color.Black, onClick = { cameraPermission.launch() } ) } 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 index c8eacf12a..1fbe7ae69 100644 --- 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 @@ -10,6 +10,7 @@ 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 @@ -24,6 +25,10 @@ internal fun ScannerNavigationBar( 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() @@ -31,24 +36,31 @@ internal fun ScannerNavigationBar( 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, - config = config, - state = NavigationBarState( - contactDmUnreadCount = state.contactDmUnreadCount, - tipUnreadCount = state.tipsUnreadCount, - showToast = billState.showToast && billState.toast != null, - toastText = billState.toast?.formattedAmount, - isPaused = isPaused, - ), + 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) } - onAction(item) }, ) } diff --git a/apps/flipcash/shared/authentication/src/main/kotlin/com/flipcash/app/auth/AuthManager.kt b/apps/flipcash/shared/authentication/src/main/kotlin/com/flipcash/app/auth/AuthManager.kt index a3ed02526..f07255df6 100644 --- a/apps/flipcash/shared/authentication/src/main/kotlin/com/flipcash/app/auth/AuthManager.kt +++ b/apps/flipcash/shared/authentication/src/main/kotlin/com/flipcash/app/auth/AuthManager.kt @@ -121,7 +121,12 @@ class AuthManager @Inject constructor( .onSuccess { onInitialized() } } - LookupResult.NoAccountFound -> Unit + // No account on this device: resolve to a terminal LoggedOut state so the + // launch router shows login. Leaving it Unknown makes the transient Unknown + // (emitted on every cold start, before soft-login resolves) ambiguous with + // "no account" — and navigating to login on that transient state tears down + // MainRoot's auth observer before Ready lands. + LookupResult.NoAccountFound -> userManager.set(AuthState.LoggedOut) is LookupResult.TemporaryAccountCreated -> { userManager.establish(entropy = result.entropy) userManager.set(AuthState.Onboarding(result.resumePoint)) 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 b9cd1cc4f..21ca205be 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 @@ -138,6 +138,15 @@ 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 + override val launched: Boolean = false + override val visible: Boolean = true + override val persistLogOut: Boolean = true + } + companion object { val entries: List> get() = FeatureFlagEntries.entries @@ -162,6 +171,7 @@ val FeatureFlag<*>.title: String FeatureFlag.GiveUsdf -> "Give/Send USDF" FeatureFlag.ShowNetworkState -> "Network Offline Indicator" FeatureFlag.FrostedTipCard -> "Frosted Tip Card" + FeatureFlag.NewUi -> "New UI" } val FeatureFlag<*>.message: String @@ -177,6 +187,7 @@ val FeatureFlag<*>.message: String FeatureFlag.GiveUsdf -> "When enabled, you'll gain the ability to send USDF directly and give it as cash" 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/region-selection/ui/src/main/kotlin/com/flipcash/app/currency/internal/RegionSelectionScreen.kt b/apps/flipcash/shared/region-selection/ui/src/main/kotlin/com/flipcash/app/currency/internal/RegionSelectionScreen.kt index f4678eeb6..bc47dfb9b 100644 --- a/apps/flipcash/shared/region-selection/ui/src/main/kotlin/com/flipcash/app/currency/internal/RegionSelectionScreen.kt +++ b/apps/flipcash/shared/region-selection/ui/src/main/kotlin/com/flipcash/app/currency/internal/RegionSelectionScreen.kt @@ -23,6 +23,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import kotlin.time.Duration.Companion.milliseconds @Composable internal fun RegionSelectionScreen(viewModel: RegionSelectionViewModel) { @@ -38,7 +39,7 @@ internal fun RegionSelectionScreen(viewModel: RegionSelectionViewModel) { .onEach { if (keyboard.visible) { keyboard.hide() - delay(500.scaled(animationScale)) + delay(500.scaled(animationScale).milliseconds) } navigator.pop() }.launchIn(this) 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 a42c6d5fb..1472e50f1 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 @@ -71,7 +71,7 @@ class SelectTokenViewModel @Inject constructor( } sealed interface Event { - data class OnRateChanged(val rate: Rate): Event + data class OnRateChanged(val rate: Rate) : Event data class OnPurposeChanged(val purpose: TokenPurpose) : Event data class OnTokensUpdated(val tokens: List) : Event @@ -82,7 +82,7 @@ class SelectTokenViewModel @Inject constructor( data class OpenScreen(val route: AppRoute) : Event - data class OnCanGiveUsdf(val enabled: Boolean): Event + data class OnCanGiveUsdf(val enabled: Boolean) : Event } init { @@ -133,7 +133,13 @@ class SelectTokenViewModel @Inject constructor( balance = balance, appreciation = appreciation, displayName = when (purpose) { - TokenPurpose.Balance -> it.token.name + TokenPurpose.Balance -> { + if (it.token.address == Mint.usdf && featureFlags.get(FeatureFlag.NewUi)) { + resources.getString(R.string.displayName_dollars) + } else { + it.token.name + } + } is TokenPurpose.Swap, is TokenPurpose.LaunchFunding, @@ -141,7 +147,11 @@ class SelectTokenViewModel @Inject constructor( TokenPurpose.Deposit, TokenPurpose.Withdraw -> { if (it.token.address == Mint.usdf) { - resources.getString(R.string.displayName_usdf) + if (featureFlags.get(FeatureFlag.NewUi)) { + resources.getString(R.string.displayName_dollars) + } else { + resources.getString(R.string.displayName_usdf) + } } else { it.token.name } diff --git a/settings.gradle.kts b/settings.gradle.kts index b2c186780..a3db04149 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -98,6 +98,7 @@ 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",