Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/flipcash/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down Expand Up @@ -363,4 +364,5 @@ dependencies {
testImplementation(libs.kotlin.test.junit)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.mockk)
testImplementation(libs.robolectric)
}
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ internal fun App(
val analytics = rememberAnalytics()
val viewModel = getActivityScopedViewModel<HomeViewModel>()
val requireBiometrics by viewModel.requireBiometrics.collectAsStateWithLifecycle()
val canSwitchAccounts by viewModel.canSwitchAccounts.collectAsStateWithLifecycle()
val biometricsState = rememberBiometricsState(
requireBiometrics = requireBiometrics,
onError = { error ->
Expand Down Expand Up @@ -200,6 +201,7 @@ internal fun App(
barManager = barManager,
cardExpansion = cardExpansion,
deepLink = { deepLink },
canSwitchAccounts = canSwitchAccounts,
onPendingAction = { action ->
deeplinkHandled = true
when (action) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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<Boolean> = combine(
featureFlagController.observeOverride(),
userFlags.resolvedFlags,
) { override, flags -> override || flags.isStaff.effectiveValue }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), initialValue = false)

private val _requireBiometrics = MutableStateFlow<Boolean?>(null)
val requireBiometrics = _requireBiometrics.stateIn(
viewModelScope,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 },
Expand Down
Original file line number Diff line number Diff line change
@@ -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<NavKey>(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<NavKey>(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(),
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ComponentActivity>()

private val haptics = mutableListOf<HapticFeedbackType>()
private val clicks = mutableListOf<NavBarButton>()
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)
}
}
Loading