From 89c2ce33ef3811344618ee37cc6a30ac56f90334 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 30 Sep 2026 10:08:52 -0400 Subject: [PATCH] feat(home): open the account switcher on a long-press of the You tab Matches iOS HomeTabView.handleLongPress(on:). Only the You tab takes the long-press, and only behind the same gate as Switch Accounts in Advanced Features (beta flags unlocked, or staff). With the gate closed the tab has no long-click at all, so there is no haptic and a long hold still selects it. The switcher is the existing AccountSelection route, opened as a sheet. --- apps/flipcash/app/build.gradle.kts | 2 + .../com/flipcash/app/internal/ui/App.kt | 2 + .../app/internal/ui/AppNavigationBar.kt | 19 ++++ .../flipcash/app/internal/ui/HomeViewModel.kt | 17 +++ .../app/internal/ui/navigation/AppContent.kt | 2 + .../app/internal/ui/YouTabLongClickTest.kt | 50 +++++++++ .../com/flipcash/app/core/ui/NavigationBar.kt | 14 ++- .../app/core/ui/NavigationBarLongPressTest.kt | 103 ++++++++++++++++++ 8 files changed, 206 insertions(+), 3 deletions(-) create mode 100644 apps/flipcash/app/src/test/kotlin/com/flipcash/app/internal/ui/YouTabLongClickTest.kt create mode 100644 apps/flipcash/core-ui/src/test/kotlin/com/flipcash/app/core/ui/NavigationBarLongPressTest.kt diff --git a/apps/flipcash/app/build.gradle.kts b/apps/flipcash/app/build.gradle.kts index 7080336941..357ccdf8c0 100644 --- a/apps/flipcash/app/build.gradle.kts +++ b/apps/flipcash/app/build.gradle.kts @@ -223,6 +223,7 @@ dependencies { implementation(project(":apps:flipcash:shared:authentication")) implementation(project(":apps:flipcash:shared:bill-customization")) implementation(project(":apps:flipcash:shared:featureflags")) + implementation(project(":apps:flipcash:shared:userflags")) implementation(project(":apps:flipcash:shared:router")) implementation(project(":apps:flipcash:shared:session")) implementation(project(":apps:flipcash:shared:google-play-billing")) @@ -363,4 +364,5 @@ dependencies { testImplementation(libs.kotlin.test.junit) testImplementation(libs.kotlinx.coroutines.test) testImplementation(libs.mockk) + testImplementation(libs.robolectric) } 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 b27653a7d4..cff981eea8 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 @@ -103,6 +103,7 @@ internal fun App( val analytics = rememberAnalytics() val viewModel = getActivityScopedViewModel() val requireBiometrics by viewModel.requireBiometrics.collectAsStateWithLifecycle() + val canSwitchAccounts by viewModel.canSwitchAccounts.collectAsStateWithLifecycle() val biometricsState = rememberBiometricsState( requireBiometrics = requireBiometrics, onError = { error -> @@ -200,6 +201,7 @@ internal fun App( barManager = barManager, cardExpansion = cardExpansion, deepLink = { deepLink }, + canSwitchAccounts = canSwitchAccounts, onPendingAction = { action -> deeplinkHandled = true when (action) { diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/AppNavigationBar.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/AppNavigationBar.kt index 0312ed898c..1441684807 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/AppNavigationBar.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/AppNavigationBar.kt @@ -21,6 +21,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flipcash.app.cardexpand.CardExpansionController import com.flipcash.app.core.AppRoute import com.flipcash.app.core.LocalUserManager +import com.flipcash.app.core.extensions.openAsSheet import dev.chrisbanes.haze.HazeState import com.flipcash.app.core.navigation.NavBarButton import com.flipcash.app.core.navigation.asNavBarTab @@ -60,6 +61,8 @@ internal fun AppNavigationBar( // the controller rather than a Float so the progress is read inside a graphicsLayer and a frame of // the expansion doesn't recompose the bar. cardExpansion: CardExpansionController? = null, + // The same gate as Switch Accounts in Advanced Features: beta flags unlocked, or staff. + canSwitchAccounts: Boolean = false, ) { // 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. @@ -133,12 +136,28 @@ internal fun AppNavigationBar( }, hazeState = hazeState, avatar = avatar, + onYouTabLongClick = youTabLongClick(canSwitchAccounts, navigator), ) } } } } +/** + * What a long-press on the You tab does: open the account switcher as a sheet, or nothing while + * [canSwitchAccounts] is closed. Mirrors iOS `HomeTabView.handleLongPress(on:)`. + * + * The switcher is the same screen Advanced Features pushes. At a sheet's root its app bar shows a + * Close, and choosing an account logs out, which App.kt answers by replacing the stack — sheet + * included — with onboarding for the chosen account. + */ +internal fun youTabLongClick(canSwitchAccounts: Boolean, navigator: CodeNavigator): (() -> Unit)? = + if (canSwitchAccounts) { + { navigator.openAsSheet(AppRoute.Menu.AccountSelection) } + } else { + null + } + /** * The account's own photo, ready to drop into the You tab, or null when there isn't one. * diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/HomeViewModel.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/HomeViewModel.kt index 7757b3d9dc..3f7370947a 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/HomeViewModel.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/HomeViewModel.kt @@ -6,7 +6,9 @@ import com.flipcash.app.android.R import com.flipcash.app.appsettings.AppSettingValue import com.flipcash.app.appsettings.AppSettingsCoordinator import com.flipcash.app.auth.AuthManager +import com.flipcash.app.featureflags.FeatureFlagController import com.flipcash.app.shareable.ShareSheetController +import com.flipcash.app.userflags.UserFlagsCoordinator import com.flipcash.services.user.UserManager import com.getcode.manager.BottomBarAction import com.getcode.manager.BottomBarManager @@ -15,6 +17,8 @@ import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import javax.inject.Inject @@ -26,8 +30,21 @@ internal class HomeViewModel @Inject constructor( private val resources: ResourceHelper, private val appSettingsCoordinator: AppSettingsCoordinator, private val shareSheetController: ShareSheetController, + featureFlagController: FeatureFlagController, + userFlags: UserFlagsCoordinator, ) : ViewModel() { + /** + * Whether a long-press on the You tab opens the account switcher. The same gate that shows + * Switch Accounts in Advanced Features — beta flags unlocked, or staff — so the two entry + * points always agree. iOS gates both on `betaFlags.canSwitchAccounts`. + */ + val canSwitchAccounts: StateFlow = combine( + featureFlagController.observeOverride(), + userFlags.resolvedFlags, + ) { override, flags -> override || flags.isStaff.effectiveValue } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), initialValue = false) + private val _requireBiometrics = MutableStateFlow(null) val requireBiometrics = _requireBiometrics.stateIn( viewModelScope, diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppContent.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppContent.kt index 87b47cfde2..9e3be28962 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppContent.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppContent.kt @@ -84,6 +84,7 @@ internal fun AppContent( cardExpansion: CardExpansionController, deepLink: () -> DeepLink?, onPendingAction: (DeeplinkAction) -> Unit = {}, + canSwitchAccounts: Boolean = false, ) { // The nav bar is a single persistent overlay at the app root (below), so tab switches stay // seamless — one instance, sliding selection pill, one haze source. It's a bottom OVERLAY over the @@ -230,6 +231,7 @@ internal fun AppContent( // The bar fades itself out with the wallet's card expansion — and drops out of the tree at // the end of the fade, so an invisible bar can't be tapped. See AppNavigationBar. cardExpansion = cardExpansion, + canSwitchAccounts = canSwitchAccounts, modifier = Modifier .align(Alignment.BottomCenter) .measured { if (it.height > tabBarHeight.value) tabBarHeight.value = it.height }, diff --git a/apps/flipcash/app/src/test/kotlin/com/flipcash/app/internal/ui/YouTabLongClickTest.kt b/apps/flipcash/app/src/test/kotlin/com/flipcash/app/internal/ui/YouTabLongClickTest.kt new file mode 100644 index 0000000000..80edf2e3fc --- /dev/null +++ b/apps/flipcash/app/src/test/kotlin/com/flipcash/app/internal/ui/YouTabLongClickTest.kt @@ -0,0 +1,50 @@ +package com.flipcash.app.internal.ui + +import androidx.navigation3.runtime.NavBackStack +import androidx.navigation3.runtime.NavKey +import com.flipcash.app.core.AppRoute +import com.getcode.navigation.core.CodeNavigator +import com.getcode.navigation.core.EmptyCodeNavigator +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +/** + * The You tab's long-press opens the account switcher only behind the Switch Accounts gate. The + * tab bar attaches no long-press at all when this is null, so null is "nothing happens". + */ +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +class YouTabLongClickTest { + + private fun navigatorOn(tab: AppRoute.Tabs) = CodeNavigator( + backStack = NavBackStack(tab), + resultStore = EmptyCodeNavigator.resultStore, + onRootReached = {}, + ) + + @Test + fun `gate closed gives the tab no long-press`() { + val navigator = navigatorOn(AppRoute.Tabs.Menu) + + assertNull(youTabLongClick(canSwitchAccounts = false, navigator = navigator)) + assertEquals(listOf(AppRoute.Tabs.Menu), navigator.backStack.toList()) + } + + @Test + fun `gate open presents the account switcher as a sheet`() { + val navigator = navigatorOn(AppRoute.Tabs.Menu) + + val onLongClick = assertNotNull(youTabLongClick(canSwitchAccounts = true, navigator = navigator)) + onLongClick() + + assertEquals( + listOf(AppRoute.Tabs.Menu, AppRoute.Main.Sheet(AppRoute.Menu.AccountSelection)), + navigator.backStack.toList(), + ) + } +} diff --git a/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/NavigationBar.kt b/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/NavigationBar.kt index 2d604e7e41..048325f8a1 100644 --- a/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/NavigationBar.kt +++ b/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/NavigationBar.kt @@ -7,7 +7,7 @@ import androidx.compose.animation.core.spring import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border -import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints @@ -96,6 +96,10 @@ fun NavigationBar( // avatar and passes null when there is no photo. The modifier handed back already sizes, clips // and fades the slot; the avatar only has to fill it. avatar: (@Composable (Modifier) -> Unit)? = null, + // Long-pressing the You tab opens the account switcher, as on iOS. Null leaves the tab with no + // long-press at all — no haptic, and a long hold still selects it — which is what a caller + // passes while the switcher's gate is closed. The other tabs never take a long-press. + onYouTabLongClick: (() -> Unit)? = null, ) { val order = NavBarButton.tabs if (order.isEmpty()) return @@ -176,10 +180,14 @@ fun NavigationBar( // Deliberately unclipped: the unread badge overhangs the icon's top-right // corner and a clip would shave it. Safe because the click indication is // null, so there is no ripple that needs bounding. - .clickable( + // combinedClickable fires the long-press haptic itself, and only when + // onLongClick is non-null, so a closed gate stays silent. + .combinedClickable( interactionSource = remember { MutableInteractionSource() }, indication = null, - ) { onButtonClick(button) }, + onLongClick = onYouTabLongClick.takeIf { button == NavBarButton.TipCard }, + onClick = { onButtonClick(button) }, + ), contentAlignment = Alignment.Center, ) { Box { diff --git a/apps/flipcash/core-ui/src/test/kotlin/com/flipcash/app/core/ui/NavigationBarLongPressTest.kt b/apps/flipcash/core-ui/src/test/kotlin/com/flipcash/app/core/ui/NavigationBarLongPressTest.kt new file mode 100644 index 0000000000..80ee8df385 --- /dev/null +++ b/apps/flipcash/core-ui/src/test/kotlin/com/flipcash/app/core/ui/NavigationBarLongPressTest.kt @@ -0,0 +1,103 @@ +package com.flipcash.app.core.ui + +import androidx.activity.ComponentActivity +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.hapticfeedback.HapticFeedback +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.longClick +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTouchInput +import com.flipcash.app.core.navigation.NavBarButton +import com.flipcash.app.theme.FlipcashPreview +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.test.assertEquals + +/** + * Long-pressing the You tab opens the account switcher (iOS `HomeTabView.handleLongPress(on:)`). + * The caller passes a null callback while the gate is closed; then the tab must behave as before — + * no haptic, and the hold still selects it. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class NavigationBarLongPressTest { + + @get:Rule + val composeRule = createAndroidComposeRule() + + private val haptics = mutableListOf() + private val clicks = mutableListOf() + private var longClicks = 0 + + private fun setBar(onYouTabLongClick: (() -> Unit)?) { + composeRule.setContent { + FlipcashPreview { + CompositionLocalProvider( + LocalHapticFeedback provides object : HapticFeedback { + override fun performHapticFeedback(hapticFeedbackType: HapticFeedbackType) { + haptics += hapticFeedbackType + } + }, + ) { + NavigationBar( + state = rememberNavigationBarState(selectedTab = NavBarButton.Wallet), + onButtonClick = { clicks += it }, + onYouTabLongClick = onYouTabLongClick, + ) + } + } + } + } + + @Test + fun `gate closed - long press on You fires no haptic and no switcher`() { + setBar(onYouTabLongClick = null) + + composeRule.onNodeWithTag("nav_tipcard").performTouchInput { longClick() } + + assertEquals(0, longClicks) + assertEquals(emptyList(), haptics) + // Unchanged from before the long-press existed: a hold is just a slow tap. + assertEquals(listOf(NavBarButton.TipCard), clicks) + } + + @Test + fun `gate open - long press on You fires the haptic and opens the switcher`() { + setBar(onYouTabLongClick = { longClicks++ }) + + composeRule.onNodeWithTag("nav_tipcard").performTouchInput { longClick() } + + assertEquals(1, longClicks) + assertEquals(listOf(HapticFeedbackType.LongPress), haptics) + assertEquals(emptyList(), clicks) + } + + @Test + fun `gate open - a tap on You still selects it`() { + setBar(onYouTabLongClick = { longClicks++ }) + + composeRule.onNodeWithTag("nav_tipcard").performClick() + + assertEquals(listOf(NavBarButton.TipCard), clicks) + assertEquals(0, longClicks) + } + + @Test + fun `gate open - other tabs take no long press`() { + setBar(onYouTabLongClick = { longClicks++ }) + + listOf("nav_scanner", "nav_wallet", "nav_chats").forEach { tag -> + composeRule.onNodeWithTag(tag).performTouchInput { longClick() } + } + + assertEquals(0, longClicks) + assertEquals(emptyList(), haptics) + assertEquals(listOf(NavBarButton.Scanner, NavBarButton.Wallet, NavBarButton.Chats), clicks) + } +}