From 4105e584254f470b4d9e14efec2b2f881956565c Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 6 Oct 2026 14:39:08 -0400 Subject: [PATCH 1/4] feat(settings): merge My Account and Advanced into one Settings screen Replaces AppRoute.Menu.MyAccount and AdvancedFeatures with a single AppRoute.Menu.Settings screen (Profile, Security, Privacy, Advanced, Account sections plus the version footer with tap-to-unlock). The features/advanced module is folded into features/myaccount and removed. EmailDeeplinkOrigin keeps its serialized "myaccount" value. --- apps/flipcash/app/build.gradle.kts | 1 - .../ui/navigation/AppScreenContent.kt | 6 +- .../navigation/BuildNavGraphForLaunchTest.kt | 4 +- .../kotlin/com/flipcash/app/core/AppRoute.kt | 4 +- .../verification/email/EmailDeeplinkOrigin.kt | 2 +- .../core/src/main/res/values/strings.xml | 3 + apps/flipcash/features/advanced/.gitignore | 2 - .../features/advanced/build.gradle.kts | 20 - .../app/advanced/AdvancedFeaturesScreen.kt | 87 ---- .../internal/AdvancedFeatureMenuItems.kt | 88 ---- .../internal/AdvancedFeaturesScreen.kt | 27 -- .../AdvancedFeaturesScreenViewModel.kt | 186 --------- .../flipcash/app/menu/internal/MenuItems.kt | 12 +- .../app/menu/internal/MenuScreenViewModel.kt | 1 - .../features/myaccount/build.gradle.kts | 3 + .../flipcash/app/myaccount/MyAccountScreen.kt | 113 ------ .../flipcash/app/myaccount/SettingsScreen.kt | 125 ++++++ .../internal/myaccount/MyAccountMenuItems.kt | 115 ------ .../myaccount/MyAccountScreenContent.kt | 68 ---- .../myaccount/MyAccountScreenViewModel.kt | 235 ----------- .../internal/settings/SettingsMenuItems.kt | 154 +++++++ .../settings/SettingsScreenContent.kt | 134 +++++++ .../internal/settings/SettingsViewModel.kt | 377 ++++++++++++++++++ .../MyAccountScreenViewModelStateTest.kt | 294 -------------- .../internal/SettingsViewModelTest.kt | 241 +++++++++++ .../flipcash/app/router/internal/AppRouter.kt | 4 +- .../app/router/internal/AppRouterTest.kt | 4 +- .../app/router/internal/NavigateToTest.kt | 4 +- .../app/router/internal/ResolveRoutesTest.kt | 12 +- settings.gradle.kts | 1 - .../com/getcode/navigation/NavMetadata.kt | 2 +- 31 files changed, 1057 insertions(+), 1272 deletions(-) delete mode 100644 apps/flipcash/features/advanced/.gitignore delete mode 100644 apps/flipcash/features/advanced/build.gradle.kts delete mode 100644 apps/flipcash/features/advanced/src/main/kotlin/com/flipcash/app/advanced/AdvancedFeaturesScreen.kt delete mode 100644 apps/flipcash/features/advanced/src/main/kotlin/com/flipcash/app/advanced/internal/AdvancedFeatureMenuItems.kt delete mode 100644 apps/flipcash/features/advanced/src/main/kotlin/com/flipcash/app/advanced/internal/AdvancedFeaturesScreen.kt delete mode 100644 apps/flipcash/features/advanced/src/main/kotlin/com/flipcash/app/advanced/internal/AdvancedFeaturesScreenViewModel.kt delete mode 100644 apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/MyAccountScreen.kt create mode 100644 apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/SettingsScreen.kt delete mode 100644 apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/myaccount/MyAccountMenuItems.kt delete mode 100644 apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/myaccount/MyAccountScreenContent.kt delete mode 100644 apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/myaccount/MyAccountScreenViewModel.kt create mode 100644 apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/settings/SettingsMenuItems.kt create mode 100644 apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/settings/SettingsScreenContent.kt create mode 100644 apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/settings/SettingsViewModel.kt delete mode 100644 apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/MyAccountScreenViewModelStateTest.kt create mode 100644 apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/SettingsViewModelTest.kt diff --git a/apps/flipcash/app/build.gradle.kts b/apps/flipcash/app/build.gradle.kts index c03a3aeb28..7e7d574171 100644 --- a/apps/flipcash/app/build.gradle.kts +++ b/apps/flipcash/app/build.gradle.kts @@ -255,7 +255,6 @@ dependencies { implementation(project(":apps:flipcash:features:balance")) implementation(project(":apps:flipcash:features:menu")) implementation(project(":apps:flipcash:features:lab")) - implementation(project(":apps:flipcash:features:advanced")) implementation(project(":apps:flipcash:features:device-logs")) implementation(project(":apps:flipcash:features:appupdates")) implementation(project(":apps:flipcash:features:deposit")) 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 3cd29c7ad2..5d94fa9c27 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 @@ -18,7 +18,6 @@ import androidx.navigation3.runtime.entryProvider import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.scene.OverlayScene import androidx.navigation3.scene.SinglePaneSceneStrategy -import com.flipcash.app.advanced.AdvancedFeaturesScreen import com.flipcash.app.devicelogs.DeviceLogsScreen import com.flipcash.app.backupkey.BackupKeyScreen import com.flipcash.app.balance.WalletScreen @@ -50,7 +49,7 @@ import com.flipcash.app.login.OnboardingFlowScreen import com.flipcash.app.menu.MenuScreen import com.flipcash.app.myaccount.BlocklistScreen import com.flipcash.app.myaccount.UserProfileScreen -import com.flipcash.app.myaccount.MyAccountScreen +import com.flipcash.app.myaccount.SettingsScreen import com.flipcash.app.scanner.ScannerScreen import com.flipcash.app.shareapp.ShareAppScreen import com.flipcash.app.tokens.SwapFlowScreen @@ -169,10 +168,9 @@ fun appEntryProvider( // Menu annotatedEntry { key -> LabsScreen(onboarding = key.onboarding) } annotatedEntry { UserProfileScreen() } - annotatedEntry { MyAccountScreen() } + annotatedEntry { SettingsScreen() } annotatedEntry { BlocklistScreen() } annotatedEntry { BackupKeyScreen() } - annotatedEntry { AdvancedFeaturesScreen() } annotatedEntry { DeviceLogsScreen() } annotatedEntry { AccountSelectionScreen() } diff --git a/apps/flipcash/app/src/test/kotlin/com/flipcash/app/internal/ui/navigation/BuildNavGraphForLaunchTest.kt b/apps/flipcash/app/src/test/kotlin/com/flipcash/app/internal/ui/navigation/BuildNavGraphForLaunchTest.kt index 0f98384de1..dc056923be 100644 --- a/apps/flipcash/app/src/test/kotlin/com/flipcash/app/internal/ui/navigation/BuildNavGraphForLaunchTest.kt +++ b/apps/flipcash/app/src/test/kotlin/com/flipcash/app/internal/ui/navigation/BuildNavGraphForLaunchTest.kt @@ -232,9 +232,9 @@ class BuildNavGraphForLaunchTest { DeeplinkAction.Navigate( listOf( AppRoute.Tabs.Menu, - AppRoute.Menu.MyAccount, + AppRoute.Menu.Settings, AppRoute.Verification( - origin = AppRoute.Menu.MyAccount, + origin = AppRoute.Menu.Settings, includePhone = false, email = "test@example.com", emailVerificationCode = "123456", 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 c42bbe379e..d1fbdb5b24 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 @@ -316,15 +316,13 @@ sealed interface AppRoute : NavKey, Parcelable { @Parcelize sealed interface Menu : AppRoute { @Serializable - data object MyAccount : Menu + data object Settings : Menu @Serializable data object BackupKey : Menu @Serializable data object Blocklist: Menu @Serializable - data object AdvancedFeatures : Menu - @Serializable data object DeviceLogs : Menu @Serializable data object AccountSelection : Menu diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/verification/email/EmailDeeplinkOrigin.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/verification/email/EmailDeeplinkOrigin.kt index 7ae6179299..af532c2f24 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/verification/email/EmailDeeplinkOrigin.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/verification/email/EmailDeeplinkOrigin.kt @@ -38,7 +38,7 @@ sealed class EmailDeeplinkOrigin { fun fromRoute(route: AppRoute?): EmailDeeplinkOrigin? { return when (route) { is AppRoute.Token.Swap -> OnRamp(route) - is AppRoute.Menu.MyAccount -> MyAccount + is AppRoute.Menu.Settings -> MyAccount else -> null } } diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 6f4b64e593..98ccf249ca 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -673,6 +673,9 @@ Contracts User Flags Account + Profile + Security + Privacy User Flags Purchase diff --git a/apps/flipcash/features/advanced/.gitignore b/apps/flipcash/features/advanced/.gitignore deleted file mode 100644 index 9f2a078806..0000000000 --- a/apps/flipcash/features/advanced/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -build/ -.gradle/ diff --git a/apps/flipcash/features/advanced/build.gradle.kts b/apps/flipcash/features/advanced/build.gradle.kts deleted file mode 100644 index 4c8b7dd01b..0000000000 --- a/apps/flipcash/features/advanced/build.gradle.kts +++ /dev/null @@ -1,20 +0,0 @@ -plugins { - alias(libs.plugins.flipcash.android.feature) -} - -android { - namespace = "${Gradle.flipcashNamespace}.features.advanced" -} - -dependencies { - implementation(project(":apps:flipcash:features:device-logs")) - implementation(project(":apps:flipcash:shared:authentication")) - implementation(project(":apps:flipcash:shared:bill-customization")) - implementation(project(":apps:flipcash:shared:featureflags")) - implementation(project(":apps:flipcash:shared:menu")) - 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/advanced/src/main/kotlin/com/flipcash/app/advanced/AdvancedFeaturesScreen.kt b/apps/flipcash/features/advanced/src/main/kotlin/com/flipcash/app/advanced/AdvancedFeaturesScreen.kt deleted file mode 100644 index 3c03919518..0000000000 --- a/apps/flipcash/features/advanced/src/main/kotlin/com/flipcash/app/advanced/AdvancedFeaturesScreen.kt +++ /dev/null @@ -1,87 +0,0 @@ -package com.flipcash.app.advanced - -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.hilt.navigation.compose.hiltViewModel -import com.flipcash.app.advanced.internal.AdvancedFeaturesScreen -import com.flipcash.app.advanced.internal.AdvancedFeaturesScreenViewModel -import com.flipcash.app.bill.customization.Event -import com.flipcash.app.bill.customization.LocalBillPlaygroundController -import com.flipcash.app.core.AppRoute -import com.flipcash.core.R -import com.getcode.navigation.core.LocalCodeNavigator -import com.getcode.opencode.model.financial.Token -import com.getcode.opencode.model.financial.usdf -import com.getcode.ui.components.AppBarWithTitle -import kotlinx.coroutines.flow.filterIsInstance -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach - -@Composable -fun AdvancedFeaturesScreen() { - val navigator = LocalCodeNavigator.current - val billPlayground = LocalBillPlaygroundController.current - val viewModel = hiltViewModel() - - Column( - modifier = Modifier.fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - AppBarWithTitle( - title = stringResource(R.string.title_advancedFeatures), - titleAlignment = Alignment.CenterHorizontally, - onBackIconClicked = { navigator.pop() } - ) - - AdvancedFeaturesScreen(viewModel) - } - - LaunchedEffect(viewModel) { - viewModel.eventFlow - .filterIsInstance() - .onEach { navigator.push(it.screen) } - .launchIn(this) - } - - LaunchedEffect(viewModel) { - viewModel.eventFlow - .filterIsInstance() - .onEach { - navigator.hide() - billPlayground.dispatchEvent(Event.Load()) - } - .launchIn(this) - } - - LaunchedEffect(viewModel) { - viewModel.eventFlow - .filterIsInstance() - .onEach { navigator.push(AppRoute.Menu.BackupKey) } - .launchIn(this) - } - - LaunchedEffect(viewModel) { - viewModel.eventFlow - .filterIsInstance() - .onEach { - navigator.hide() - navigator.replaceAll(AppRoute.OnboardingFlow()) - } - .launchIn(this) - } - - LaunchedEffect(viewModel) { - viewModel.eventFlow - .filterIsInstance() - .onEach { - navigator.hide() - navigator.replaceAll(AppRoute.OnboardingFlow()) - } - .launchIn(this) - } -} diff --git a/apps/flipcash/features/advanced/src/main/kotlin/com/flipcash/app/advanced/internal/AdvancedFeatureMenuItems.kt b/apps/flipcash/features/advanced/src/main/kotlin/com/flipcash/app/advanced/internal/AdvancedFeatureMenuItems.kt deleted file mode 100644 index a03a2151c4..0000000000 --- a/apps/flipcash/features/advanced/src/main/kotlin/com/flipcash/app/advanced/internal/AdvancedFeatureMenuItems.kt +++ /dev/null @@ -1,88 +0,0 @@ -package com.flipcash.app.advanced.internal - -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Science -import androidx.compose.material.icons.outlined.Description -import androidx.compose.material.icons.outlined.Palette -import androidx.compose.runtime.Composable -import androidx.compose.ui.graphics.painter.Painter -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import com.flipcash.app.core.AppRoute -import com.flipcash.app.menu.FullMenuItem -import com.flipcash.app.menu.StaffMenuItem -import com.flipcash.core.R -import com.getcode.util.resources.icons.Delete - -/** - * Node 9279:121978. Access Key, Log Out and Delete Account moved here from My Account — they're - * recovery/destructive actions, not account details. - */ -internal data object AccessKey : FullMenuItem() { - override val icon: Painter - @Composable get() = painterResource(R.drawable.ic_hardware_security_key) - override val name: String - @Composable get() = stringResource(R.string.title_accessKey) - override val action: AdvancedFeaturesScreenViewModel.Event = - AdvancedFeaturesScreenViewModel.Event.OnAccessKeyClicked -} - -internal data object BillCustomizer : FullMenuItem() { - override val icon: Painter - @Composable get() = rememberVectorPainter(Icons.Outlined.Palette) - override val name: String - @Composable get() = stringResource(R.string.title_billCustomizer) - override val action: AdvancedFeaturesScreenViewModel.Event = AdvancedFeaturesScreenViewModel.Event.OpenBillPlayground -} - -internal data object DeviceLogs : FullMenuItem() { - override val icon: Painter - @Composable get() = rememberVectorPainter(Icons.Outlined.Description) - override val name: String - @Composable get() = stringResource(R.string.title_deviceLogs) - override val action: AdvancedFeaturesScreenViewModel.Event = - AdvancedFeaturesScreenViewModel.Event.OpenScreen(AppRoute.Menu.DeviceLogs) -} - -internal data object BetaFlags : FullMenuItem() { - override val icon: Painter - @Composable get() = rememberVectorPainter(Icons.Filled.Science) - override val name: String - @Composable get() = stringResource(R.string.title_betaFlags) - override val action: AdvancedFeaturesScreenViewModel.Event = - AdvancedFeaturesScreenViewModel.Event.OpenScreen(AppRoute.Menu.Lab()) -} - -/** - * Staff/beta only, as it was before the account list replaced Google's Password Manager behind it. - * The switcher is still a beta tool, so it keeps the beta badge [StaffMenuItem] carries and sits - * next to the other one rather than on the You tab. - */ -internal data object SwitchAccount : StaffMenuItem() { - override val icon: Painter - @Composable get() = painterResource(R.drawable.ic_menu_switchaccounts) - override val name: String - @Composable get() = stringResource(R.string.title_switchAccounts) - override val action: AdvancedFeaturesScreenViewModel.Event = - AdvancedFeaturesScreenViewModel.Event.OpenScreen(AppRoute.Menu.AccountSelection) -} - -internal data object LogOut : FullMenuItem() { - override val icon: Painter - @Composable get() = painterResource(R.drawable.ic_menu_logout) - override val name: String - @Composable get() = stringResource(R.string.action_logout) - override val action: AdvancedFeaturesScreenViewModel.Event = - AdvancedFeaturesScreenViewModel.Event.OnLogOutClicked -} - -internal data object DeleteAccount : FullMenuItem() { - override val icon: Painter - @Composable get() = rememberVectorPainter(ImageVector.Delete) - override val name: String - @Composable get() = stringResource(R.string.action_deleteAccount) - override val action: AdvancedFeaturesScreenViewModel.Event = - AdvancedFeaturesScreenViewModel.Event.OnDeleteAccountClicked -} diff --git a/apps/flipcash/features/advanced/src/main/kotlin/com/flipcash/app/advanced/internal/AdvancedFeaturesScreen.kt b/apps/flipcash/features/advanced/src/main/kotlin/com/flipcash/app/advanced/internal/AdvancedFeaturesScreen.kt deleted file mode 100644 index 61a6504ec6..0000000000 --- a/apps/flipcash/features/advanced/src/main/kotlin/com/flipcash/app/advanced/internal/AdvancedFeaturesScreen.kt +++ /dev/null @@ -1,27 +0,0 @@ -package com.flipcash.app.advanced.internal - -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.flipcash.app.menu.MenuList - -@Composable -internal fun AdvancedFeaturesScreen(viewModel: AdvancedFeaturesScreenViewModel) { - val state by viewModel.stateFlow.collectAsStateWithLifecycle() - AdvancedFeaturesScreenContent(state = state, dispatch = viewModel::dispatchEvent) -} - -@Composable -private fun AdvancedFeaturesScreenContent( - state: AdvancedFeaturesScreenViewModel.State, - dispatch: (AdvancedFeaturesScreenViewModel.Event) -> Unit -) { - MenuList( - modifier = Modifier.fillMaxSize(), - items = state.items, - showChevrons = true, - onItemClick = { dispatch(it.action) } - ) -} \ No newline at end of file diff --git a/apps/flipcash/features/advanced/src/main/kotlin/com/flipcash/app/advanced/internal/AdvancedFeaturesScreenViewModel.kt b/apps/flipcash/features/advanced/src/main/kotlin/com/flipcash/app/advanced/internal/AdvancedFeaturesScreenViewModel.kt deleted file mode 100644 index 9eaca28ea8..0000000000 --- a/apps/flipcash/features/advanced/src/main/kotlin/com/flipcash/app/advanced/internal/AdvancedFeaturesScreenViewModel.kt +++ /dev/null @@ -1,186 +0,0 @@ -package com.flipcash.app.advanced.internal - -import androidx.lifecycle.viewModelScope -import com.flipcash.app.auth.AuthManager -import com.flipcash.app.core.AppRoute -import com.flipcash.app.featureflags.BetaFeature -import com.flipcash.app.featureflags.FeatureFlagController -import com.flipcash.app.menu.MenuItem -import com.flipcash.app.menu.StaffMenuItem -import com.flipcash.app.userflags.UserFlagsCoordinator -import com.flipcash.core.R -import com.flipcash.libs.coroutines.DispatcherProvider -import com.getcode.manager.BottomBarAction -import com.getcode.manager.BottomBarManager -import com.getcode.util.resources.ResourceHelper -import com.getcode.view.BaseViewModel -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.filterIsInstance -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.launch -import javax.inject.Inject - -private val FullMenuList: List> = buildList { - add(AccessKey) - add(BetaFlags) - add(DeviceLogs) -// add(BillCustomizer) - add(SwitchAccount) - add(LogOut) - add(DeleteAccount) -} - -@HiltViewModel -internal class AdvancedFeaturesScreenViewModel @Inject constructor( - featureFlagController: FeatureFlagController, - userFlags: UserFlagsCoordinator, - resources: ResourceHelper, - authManager: AuthManager, - dispatchers: DispatcherProvider, -) : BaseViewModel( - initialState = State(), - updateStateForEvent = updateStateForEvent, - defaultDispatcher = dispatchers.Default, -) { - data class State( - val isBetaEnabled: Boolean = false, - val flags: List = emptyList(), - // Default hides staff-only AND flag-gated items until the real state loads, so a beta-gated - // row never flashes before its flag resolves. - val items: List> = - FullMenuList.filterNot { it is StaffMenuItem<*> || it.featureFlag != null }, - ) - - sealed interface Event { - data class OnBetaFeaturesUnlocked( - val unlocked: Boolean, - val flags: List = emptyList(), - ) : Event - data class OpenScreen(val screen: AppRoute) : Event - data object OpenBillPlayground : Event - data object OnAccessKeyClicked : Event - data object OnViewAccessKey : Event - data object OnLogOutClicked : Event - data object OnLoggedOutCompletely : Event - data object OnDeleteAccountClicked : Event - data object OnAccountDeleted : Event - } - - init { - combine( - featureFlagController.observeOverride(), - userFlags.resolvedFlags.map { it.isStaff.effectiveValue }, - featureFlagController.observe(), - ) { override, isStaff, flags -> - dispatchEvent(Event.OnBetaFeaturesUnlocked(override || isStaff, flags)) - }.launchIn(viewModelScope) - - eventFlow - .filterIsInstance() - .onEach { - BottomBarManager.showAlert( - title = resources.getString(R.string.prompt_title_viewAccessKey), - message = resources.getString(R.string.prompt_description_viewAccessKey), - showScrim = true, - showCancel = true, - actions = listOf( - BottomBarAction( - text = resources.getString(R.string.action_viewAccessKey), - onClick = { dispatchEvent(Event.OnViewAccessKey) } - ) - ), - ) - }.launchIn(viewModelScope) - - eventFlow - .filterIsInstance() - .onEach { - BottomBarManager.showAlert( - title = resources.getString(R.string.prompt_title_logout), - message = resources.getString(R.string.prompt_description_logout), - actions = listOf( - BottomBarAction(resources.getString(R.string.action_logout)) { - viewModelScope.launch { - delay(150) // wait for dismiss - authManager.logout() - .onSuccess { dispatchEvent(Event.OnLoggedOutCompletely) } - .onFailure { - BottomBarManager.showError( - title = resources.getString(R.string.error_title_failedToLogOut), - message = resources.getString(R.string.error_description_failedToLogOut), - ) - } - } - }, - ), - showCancel = true, - ) - }.launchIn(viewModelScope) - - eventFlow - .filterIsInstance() - .onEach { - BottomBarManager.showAlert( - title = resources.getString(R.string.prompt_title_deleteAccount), - message = resources.getString(R.string.prompt_description_deleteAccount), - actions = listOf( - BottomBarAction(resources.getString(R.string.action_deleteAccount)) { - viewModelScope.launch { - delay(150) // wait for dismiss - authManager.deleteAndLogout() - .onSuccess { dispatchEvent(Event.OnAccountDeleted) } - .onFailure { - BottomBarManager.showError( - title = resources.getString(R.string.error_title_failedToDeleteAccount), - message = resources.getString(R.string.error_description_failedToDeleteAccount), - ) - } - } - } - ), - showCancel = true, - ) - }.launchIn(viewModelScope) - } - - internal companion object { - /** - * Staff-only rows need beta access (staff, or the version-footer override); flag-gated rows - * additionally need their flag switched on server-side. - */ - private fun buildItemList( - unlocked: Boolean, - flags: List, - ): List> = FullMenuList - .filter { it !is StaffMenuItem<*> || unlocked } - .filter { item -> - val flag = item.featureFlag ?: return@filter true - flags.find { it.flag.key == flag.key }?.enabled == true - } - - private val updateStateForEvent: (Event) -> ((State) -> State) = { event -> - when (event) { - is Event.OnBetaFeaturesUnlocked -> { state -> - state.copy( - isBetaEnabled = event.unlocked, - flags = event.flags, - items = buildItemList(unlocked = event.unlocked, flags = event.flags), - ) - } - - is Event.OpenScreen, - Event.OpenBillPlayground, - Event.OnAccessKeyClicked, - Event.OnViewAccessKey, - Event.OnLogOutClicked, - Event.OnLoggedOutCompletely, - Event.OnDeleteAccountClicked, - Event.OnAccountDeleted -> { state -> state } - } - } - } -} \ No newline at end of file diff --git a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuItems.kt b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuItems.kt index df1df18880..d8d75d1bd7 100644 --- a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuItems.kt +++ b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuItems.kt @@ -15,16 +15,6 @@ internal data object MyAccount : FullMenuItem() { override val name: String @Composable get() = stringResource(R.string.title_myAccount) override val action: MenuScreenViewModel.Event = MenuScreenViewModel.Event.OpenScreen( - AppRoute.Menu.MyAccount - ) -} - -internal data object AdvancedFeatures : FullMenuItem() { - override val icon: Painter - @Composable get() = painterResource(R.drawable.ic_maintenance) - override val name: String - @Composable get() = stringResource(R.string.title_advancedFeatures) - override val action: MenuScreenViewModel.Event = MenuScreenViewModel.Event.OpenScreen( - AppRoute.Menu.AdvancedFeatures + AppRoute.Menu.Settings ) } diff --git a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenViewModel.kt b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenViewModel.kt index 6e6177a377..bf9709da8c 100644 --- a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenViewModel.kt +++ b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenViewModel.kt @@ -66,7 +66,6 @@ private fun Fiat.formattedGate(): String = private val FullMenuList = buildList { add(MyAccount) - add(AdvancedFeatures) } @HiltViewModel diff --git a/apps/flipcash/features/myaccount/build.gradle.kts b/apps/flipcash/features/myaccount/build.gradle.kts index 033f014091..fbced78a9a 100644 --- a/apps/flipcash/features/myaccount/build.gradle.kts +++ b/apps/flipcash/features/myaccount/build.gradle.kts @@ -10,11 +10,14 @@ dependencies { testImplementation(kotlin("test")) testImplementation(libs.bundles.unit.testing) testImplementation(libs.bundles.compose.ui.testing) + testImplementation(testFixtures(project(":ui:resources"))) implementation(libs.compose.paging) implementation(project(":apps:flipcash:shared:appsettings")) + implementation(project(":apps:flipcash:shared:appupdates")) implementation(project(":apps:flipcash:shared:authentication")) + implementation(project(":apps:flipcash:shared:bill-customization")) implementation(project(":apps:flipcash:shared:blocklist")) implementation(project(":apps:flipcash:shared:common-ui")) implementation(project(":apps:flipcash:shared:contacts")) diff --git a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/MyAccountScreen.kt b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/MyAccountScreen.kt deleted file mode 100644 index 97c0d48e97..0000000000 --- a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/MyAccountScreen.kt +++ /dev/null @@ -1,113 +0,0 @@ -package com.flipcash.app.myaccount - - -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.hilt.navigation.compose.hiltViewModel -import com.flipcash.app.core.AppRoute -import com.flipcash.app.core.DisplayNameSource -import com.flipcash.app.core.userprofile.UpdateProfileStep -import com.flipcash.app.myaccount.internal.myaccount.MyAccountScreen -import com.flipcash.app.myaccount.internal.myaccount.MyAccountScreenViewModel -import com.flipcash.core.R -import com.getcode.navigation.core.LocalCodeNavigator -import com.getcode.ui.components.AppBarDefaults -import com.getcode.ui.components.AppBarWithTitle -import kotlinx.coroutines.flow.filterIsInstance -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach - -@Composable -fun MyAccountScreen() { - val navigator = LocalCodeNavigator.current - - val viewModel = hiltViewModel() - - Column( - modifier = Modifier.fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - AppBarWithTitle( - title = { - AppBarDefaults.Title( - text = stringResource(R.string.title_myAccount), - ) - }, - titleAlignment = Alignment.CenterHorizontally, - leftIcon = { AppBarDefaults.UpNavigation { navigator.pop() } }, - ) - MyAccountScreen(viewModel) - } - - LaunchedEffect(viewModel) { - viewModel.eventFlow - .filterIsInstance() - .onEach { - navigator.push( - AppRoute.UpdateUserProfile( - origin = AppRoute.Menu.MyAccount, - steps = listOf(UpdateProfileStep.Name(DisplayNameSource.MyAccount)), - ) - ) - }.launchIn(this) - } - - LaunchedEffect(viewModel) { - viewModel.eventFlow - .filterIsInstance() - .onEach { - navigator.push( - AppRoute.UpdateUserProfile( - origin = AppRoute.Menu.MyAccount, - steps = listOf(UpdateProfileStep.Username), - ) - ) - }.launchIn(this) - } - - LaunchedEffect(viewModel) { - viewModel.eventFlow - .filterIsInstance() - .onEach { - navigator.push( - AppRoute.UpdateUserProfile( - origin = AppRoute.Menu.MyAccount, - steps = listOf(UpdateProfileStep.Photo), - ) - ) - }.launchIn(this) - } - - LaunchedEffect(viewModel) { - viewModel.eventFlow - .filterIsInstance() - .onEach { - navigator.push( - AppRoute.UpdateUserProfile( - origin = AppRoute.Menu.MyAccount, - steps = listOf(UpdateProfileStep.MinimumTip), - ) - ) - } - .launchIn(this) - } - - LaunchedEffect(viewModel) { - viewModel.eventFlow - .filterIsInstance() - .onEach { navigator.push(AppRoute.Menu.UserProfile) } - .launchIn(this) - } - - LaunchedEffect(viewModel) { - viewModel.eventFlow - .filterIsInstance() - .onEach { navigator.push(AppRoute.Menu.Blocklist) } - .launchIn(this) - } -} diff --git a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/SettingsScreen.kt b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/SettingsScreen.kt new file mode 100644 index 0000000000..f6605c665e --- /dev/null +++ b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/SettingsScreen.kt @@ -0,0 +1,125 @@ +package com.flipcash.app.myaccount + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.hilt.navigation.compose.hiltViewModel +import com.flipcash.app.bill.customization.Event +import com.flipcash.app.bill.customization.LocalBillPlaygroundController +import com.flipcash.app.core.AppRoute +import com.flipcash.app.myaccount.internal.settings.SettingsScreen +import com.flipcash.app.myaccount.internal.settings.SettingsViewModel +import com.flipcash.core.R +import com.getcode.navigation.core.LocalCodeNavigator +import com.getcode.ui.components.AppBarDefaults +import com.getcode.ui.components.AppBarWithTitle +import com.getcode.ui.components.toast.LocalFloatingToastHost +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch + +@Composable +fun SettingsScreen() { + val navigator = LocalCodeNavigator.current + val billPlayground = LocalBillPlaygroundController.current + val toasts = LocalFloatingToastHost.current + + val viewModel = hiltViewModel() + + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + AppBarWithTitle( + title = { + AppBarDefaults.Title( + text = stringResource(R.string.title_settings), + ) + }, + titleAlignment = Alignment.CenterHorizontally, + leftIcon = { AppBarDefaults.UpNavigation { navigator.pop() } }, + ) + SettingsScreen(viewModel) + } + + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .onEach { + navigator.push( + AppRoute.UpdateUserProfile( + origin = AppRoute.Menu.Settings, + steps = listOf(it.step), + ) + ) + }.launchIn(this) + } + + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .onEach { navigator.push(it.screen) } + .launchIn(this) + } + + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .onEach { + navigator.hide() + billPlayground.dispatchEvent(Event.Load()) + } + .launchIn(this) + } + + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .onEach { navigator.push(AppRoute.Menu.BackupKey) } + .launchIn(this) + } + + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .onEach { + navigator.hide() + navigator.replaceAll(AppRoute.OnboardingFlow()) + } + .launchIn(this) + } + + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .onEach { + navigator.hide() + navigator.replaceAll(AppRoute.OnboardingFlow()) + } + .launchIn(this) + } + + // Hugs the message and passes taps through, so each tap still reaches the version row; each + // update swaps the text in place and restarts the timeout rather than replaying the entrance. + LaunchedEffect(viewModel, toasts) { + if (toasts == null) return@LaunchedEffect + viewModel.eventFlow + .filterIsInstance() + .onEach { event -> + launch { + toasts.show( + message = event.message, + hugContent = true, + passThrough = true, + inPlace = true, + ) + } + } + .launchIn(this) + } +} diff --git a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/myaccount/MyAccountMenuItems.kt b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/myaccount/MyAccountMenuItems.kt deleted file mode 100644 index a05037791e..0000000000 --- a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/myaccount/MyAccountMenuItems.kt +++ /dev/null @@ -1,115 +0,0 @@ -package com.flipcash.app.myaccount.internal.myaccount - -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ContactMail -import androidx.compose.material.icons.outlined.AlternateEmail -import androidx.compose.material.icons.outlined.Badge -import androidx.compose.material.icons.outlined.Block -import androidx.compose.runtime.Composable -import androidx.compose.ui.graphics.painter.Painter -import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import com.flipcash.app.core.AppRoute -import com.flipcash.app.menu.FullMenuItem -import com.flipcash.app.menu.StaffMenuItem -import com.flipcash.core.R as CoreR -import com.flipcash.features.myaccount.R - -/** - * Node 9277:121893. Account-shaped settings only — the destructive/diagnostic rows (Access Key, - * Log Out, Delete Account) moved to Advanced, and the standalone App Settings screen folded its one - * surviving toggle (Require Biometrics) in here. - * - * The row lands straight on the name step: [AppRoute.UpdateUserProfile] walks name, username then - * photo, and this is only ever about the name, so it asks for that step alone. - */ -internal data object ChangeDisplayName : FullMenuItem() { - override val icon: Painter - @Composable get() = rememberVectorPainter(Icons.Outlined.Badge) - override val name: String - @Composable get() = stringResource(CoreR.string.title_displayName) - override val action: MyAccountScreenViewModel.Event = - MyAccountScreenViewModel.Event.OnChangeDisplayNameClicked -} - -/** - * Node 9491:6297. The public `@handle`. One of two ways into the same username step — the other is - * the "You" tab's progress card — because claiming a first handle and changing an existing one are - * the same screen, differing only in what the field is prefilled with. - * - * Shown only once a handle is claimed, matching iOS `SettingsMyAccountScreen`. Claiming the first - * one belongs to the You tab's card, which carries the minimum-balance gate and disappears the - * moment `usernameGate` reads `Claimed` — exactly where this row appears. No balance gate here: the - * minimum exists to stop squatting at claim time, and an account holding a handle has cleared it. - */ -internal data object ChangeUsername : FullMenuItem() { - override val icon: Painter - @Composable get() = rememberVectorPainter(Icons.Outlined.AlternateEmail) - override val name: String - @Composable get() = stringResource(CoreR.string.title_username) - override val action: MyAccountScreenViewModel.Event = - MyAccountScreenViewModel.Event.OnChangeUsernameClicked -} - -/** - * Node 9544:20116. The avatar, on its own row. [AppRoute.UpdateUserProfile] walks name then username - * then photo, so this asks for the photo step alone — the same single-step edit the staff-only - * profile editor already pushes. - */ -internal data object ProfilePicture : FullMenuItem() { - override val icon: Painter - @Composable get() = painterResource(CoreR.drawable.ic_profile_picture) - override val name: String - @Composable get() = stringResource(CoreR.string.title_profilePicture) - override val action: MyAccountScreenViewModel.Event = - MyAccountScreenViewModel.Event.OnProfilePictureClicked -} - -/** - * The fee another user has to pay to open a DM, which the profile carries as `minDmChatInitFee`. - * The same entry screen the "You" tab's checklist opens, reached here as a standing setting rather - * than a setup step. - */ -internal data object MinimumTip : FullMenuItem() { - override val icon: Painter - @Composable get() = painterResource(CoreR.drawable.ic_coins) - override val name: String - @Composable get() = stringResource(CoreR.string.title_minimumToChat) - override val action: MyAccountScreenViewModel.Event = - MyAccountScreenViewModel.Event.OnMinimumTipClicked -} - -/** - * A toggle, not a destination — the screen renders a switch in its trailing slot and routes the tap - * through a biometric prompt. Its [action] is what a row tap dispatches, same as the switch. - */ -internal data object RequireBiometrics : FullMenuItem() { - override val icon: Painter - @Composable get() = painterResource(R.drawable.ic_biometrics) - override val name: String - @Composable get() = stringResource(CoreR.string.title_requireBiometrics) - override val action: MyAccountScreenViewModel.Event = MyAccountScreenViewModel.Event.OnBiometricsToggled -} - -internal data object Blocklist : FullMenuItem() { - override val icon: Painter - @Composable get() = rememberVectorPainter(Icons.Outlined.Block) - override val name: String - @Composable get() = stringResource(R.string.title_blocklist) - override val action: MyAccountScreenViewModel.Event = MyAccountScreenViewModel.Event.OnBlocklistClicked -} - -/** - * Staff/beta only: the whole profile editor — contact methods, photo, name. What it adds over the - * rows above is the contact methods; the name and the photo are already reachable by everyone - * through [ChangeDisplayName] and [ProfilePicture]. - */ -internal data object UserProfile : StaffMenuItem() { - override val icon: Painter - @Composable get() = rememberVectorPainter(Icons.Default.ContactMail) - override val name: String - @Composable get() = stringResource(CoreR.string.title_userProfile) - override val action: MyAccountScreenViewModel.Event = - MyAccountScreenViewModel.Event.OnContactMethodsClicked -} diff --git a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/myaccount/MyAccountScreenContent.kt b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/myaccount/MyAccountScreenContent.kt deleted file mode 100644 index 95c4d20ff3..0000000000 --- a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/myaccount/MyAccountScreenContent.kt +++ /dev/null @@ -1,68 +0,0 @@ -package com.flipcash.app.myaccount.internal.myaccount - -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.flipcash.app.menu.MenuList -import com.getcode.libs.biometrics.Biometrics -import com.getcode.ui.components.ListItemDefaults -import kotlinx.coroutines.launch - -@Composable -internal fun MyAccountScreen(viewModel: MyAccountScreenViewModel) { - val state by viewModel.stateFlow.collectAsStateWithLifecycle() - - MyAccountScreenContent(state = state, dispatch = viewModel::dispatchEvent) -} - -@Composable -private fun MyAccountScreenContent( - state: MyAccountScreenViewModel.State, - dispatch: (MyAccountScreenViewModel.Event) -> Unit -) { - val context = LocalContext.current - val scope = rememberCoroutineScope() - - // Flipping the biometrics requirement has to be authenticated by the biometrics themselves, - // so the row routes through a prompt before the toggle is dispatched. The switch is display - // only; tapping anywhere on the row (the switch included) runs this. - val toggleBiometrics = { - if (state.biometricsAvailable) { - scope.launch { - Biometrics.prompt(context, delay = 300) - .onSuccess { dispatch(MyAccountScreenViewModel.Event.OnBiometricsToggled) } - } - } - Unit - } - - MenuList( - modifier = Modifier.fillMaxSize(), - items = state.items, - // Only the biometrics row can be inert, and only when the hardware has nothing enrolled. - isItemEnabled = { item -> item != RequireBiometrics || state.biometricsAvailable }, - supportingTextFor = { item -> - state.biometricsDescription - ?.takeIf { item == RequireBiometrics } - ?.let { stringResource(it) } - }, - onItemClick = { item -> - if (item == RequireBiometrics) toggleBiometrics() else dispatch(item.action) - }, - endSlot = { item -> - if (item == RequireBiometrics) { - ListItemDefaults.Toggle( - checked = state.biometricsRequired, - enabled = state.biometricsAvailable, - ) - } else { - ListItemDefaults.Chevron() - } - } - ) -} diff --git a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/myaccount/MyAccountScreenViewModel.kt b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/myaccount/MyAccountScreenViewModel.kt deleted file mode 100644 index 31fdf5ddc0..0000000000 --- a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/myaccount/MyAccountScreenViewModel.kt +++ /dev/null @@ -1,235 +0,0 @@ -package com.flipcash.app.myaccount.internal.myaccount - -import androidx.annotation.StringRes -import androidx.lifecycle.viewModelScope -import com.flipcash.app.appsettings.AppSettingValue -import com.flipcash.app.appsettings.AppSettingsCoordinator -import com.flipcash.app.featureflags.FeatureFlagController -import com.flipcash.app.menu.MenuItem -import com.flipcash.app.menu.StaffMenuItem -import com.flipcash.app.userflags.UserFlagsCoordinator -import com.flipcash.libs.coroutines.DispatcherProvider -import com.flipcash.services.user.UserManager -import com.getcode.view.BaseViewModel -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.filterIsInstance -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onEach -import javax.inject.Inject - -// Render order. User Profile leads it, so the account being described comes before the settings -// that change it. -private val FullMenuList = buildList { - add(UserProfile) - add(ChangeDisplayName) - add(ChangeUsername) - add(ProfilePicture) - add(MinimumTip) - add(RequireBiometrics) - add(Blocklist) -} - -@HiltViewModel -internal class MyAccountScreenViewModel @Inject constructor( - private val appSettings: AppSettingsCoordinator, - featureFlagController: FeatureFlagController, - userFlags: UserFlagsCoordinator, - userManager: UserManager, - dispatchers: DispatcherProvider, -) : BaseViewModel( - initialState = State(), - updateStateForEvent = updateStateForEvent, - defaultDispatcher = dispatchers.Default, -) { - internal data class State( - val biometricsRequired: Boolean = false, - // Biometrics aren't offerable on every device: the row is hidden outright when the hardware - // isn't there, and shown-but-disabled when the hardware exists with nothing enrolled. - val biometricsSupported: Boolean = true, - val biometricsAvailable: Boolean = true, - // Why the row can't act, when it can't — e.g. the hardware is there with nothing enrolled. - @StringRes val biometricsDescription: Int? = null, - val betaUnlocked: Boolean = false, - // Whether the account holds a handle. Changing one presupposes having one. - val usernameClaimed: Boolean = false, - // Conditional rows — staff, and the handle — stay out until their real state loads, so they - // never flash in for an account that shouldn't see them. - val items: List> = buildItemList( - biometricsSupported = true, - betaUnlocked = false, - usernameClaimed = false, - ), - ) - - internal sealed interface Event { - data class OnBetaFeaturesUnlocked(val unlocked: Boolean) : Event - data class OnBiometricsSettingChanged( - val required: Boolean, - val supported: Boolean, - val available: Boolean, - @StringRes val description: Int? = null, - ) : Event - /** Dispatched only after the screen's biometric prompt succeeds. */ - data object OnBiometricsToggled : Event - data object OnChangeDisplayNameClicked : Event - data object OnEditDisplayName : Event - data class OnUsernameClaimChanged(val claimed: Boolean) : Event - data object OnChangeUsernameClicked : Event - data object OnEditUsername : Event - data object OnProfilePictureClicked : Event - data object OnEditProfilePicture : Event - data object OnMinimumTipClicked : Event - data object OnEditMinimumTip : Event - data object OnBlocklistClicked: Event - data object OnViewBlocklist: Event - data object OnContactMethodsClicked : Event - data object OnViewUserProfile : Event - } - - init { - combine( - featureFlagController.observeOverride(), - userFlags.resolvedFlags.map { it.isStaff.effectiveValue }, - ) { override, isStaff -> override || isStaff } - .onEach { dispatchEvent(Event.OnBetaFeaturesUnlocked(it)) } - .launchIn(viewModelScope) - - userManager.state - .map { it.userProfile?.username } - .map { username -> !username.isNullOrBlank() } - .distinctUntilChanged() - .onEach { dispatchEvent(Event.OnUsernameClaimChanged(it)) } - .launchIn(viewModelScope) - - appSettings.settings() - .map { items -> items.find { it.setting.type == AppSettingValue.BiometricsRequired } } - .onEach { item -> - item ?: return@onEach - dispatchEvent( - Event.OnBiometricsSettingChanged( - required = item.setting.enabled, - supported = item.visible, - available = item.available, - description = item.description, - ) - ) - }.launchIn(viewModelScope) - - eventFlow - .filterIsInstance() - .onEach { - appSettings.update( - AppSettingValue.BiometricsRequired, - !stateFlow.value.biometricsRequired, - ) - }.launchIn(viewModelScope) - - eventFlow - .filterIsInstance() - .onEach { - dispatchEvent(Event.OnEditDisplayName) - }.launchIn(viewModelScope) - - eventFlow - .filterIsInstance() - .onEach { - dispatchEvent(Event.OnEditUsername) - }.launchIn(viewModelScope) - - eventFlow - .filterIsInstance() - .onEach { - dispatchEvent(Event.OnEditProfilePicture) - }.launchIn(viewModelScope) - - eventFlow - .filterIsInstance() - .onEach { - dispatchEvent(Event.OnEditMinimumTip) - }.launchIn(viewModelScope) - - eventFlow - .filterIsInstance() - .onEach { - dispatchEvent(Event.OnViewBlocklist) - }.launchIn(viewModelScope) - - eventFlow - .filterIsInstance() - .onEach { - dispatchEvent(Event.OnViewUserProfile) - }.launchIn(viewModelScope) - } - - internal companion object { - /** - * Biometrics drops out on hardware that can't offer it; staff rows need the beta unlock; - * changing a handle needs one to already be claimed. - */ - private fun buildItemList( - biometricsSupported: Boolean, - betaUnlocked: Boolean, - usernameClaimed: Boolean, - ): List> = FullMenuList - .filterNot { it == RequireBiometrics && !biometricsSupported } - .filterNot { it == ChangeUsername && !usernameClaimed } - .filter { it !is StaffMenuItem || betaUnlocked } - - val updateStateForEvent: (Event) -> ((State) -> State) = { event -> - when (event) { - Event.OnBiometricsToggled, - Event.OnChangeDisplayNameClicked, - Event.OnEditDisplayName, - Event.OnChangeUsernameClicked, - Event.OnEditUsername, - Event.OnProfilePictureClicked, - Event.OnEditProfilePicture, - Event.OnMinimumTipClicked, - Event.OnEditMinimumTip, - Event.OnContactMethodsClicked, - Event.OnViewUserProfile, - Event.OnBlocklistClicked, - Event.OnViewBlocklist -> { state -> state } - - is Event.OnBetaFeaturesUnlocked -> { state -> - state.copy( - betaUnlocked = event.unlocked, - items = buildItemList( - biometricsSupported = state.biometricsSupported, - betaUnlocked = event.unlocked, - usernameClaimed = state.usernameClaimed, - ), - ) - } - - is Event.OnUsernameClaimChanged -> { state -> - state.copy( - usernameClaimed = event.claimed, - items = buildItemList( - biometricsSupported = state.biometricsSupported, - betaUnlocked = state.betaUnlocked, - usernameClaimed = event.claimed, - ), - ) - } - - is Event.OnBiometricsSettingChanged -> { state -> - state.copy( - biometricsRequired = event.required, - biometricsSupported = event.supported, - biometricsAvailable = event.available, - biometricsDescription = event.description, - items = buildItemList( - biometricsSupported = event.supported, - betaUnlocked = state.betaUnlocked, - usernameClaimed = state.usernameClaimed, - ), - ) - } - } - } - } -} diff --git a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/settings/SettingsMenuItems.kt b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/settings/SettingsMenuItems.kt new file mode 100644 index 0000000000..3c3d6abee5 --- /dev/null +++ b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/settings/SettingsMenuItems.kt @@ -0,0 +1,154 @@ +package com.flipcash.app.myaccount.internal.settings + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ContactMail +import androidx.compose.material.icons.filled.Science +import androidx.compose.material.icons.outlined.AlternateEmail +import androidx.compose.material.icons.outlined.Badge +import androidx.compose.material.icons.outlined.Block +import androidx.compose.material.icons.outlined.Description +import androidx.compose.material.icons.outlined.Palette +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import com.flipcash.app.core.AppRoute +import com.flipcash.app.core.DisplayNameSource +import com.flipcash.app.core.userprofile.UpdateProfileStep +import com.flipcash.app.menu.FullMenuItem +import com.flipcash.app.menu.StaffMenuItem +import com.flipcash.core.R as CoreR +import com.flipcash.features.myaccount.R +import com.getcode.util.resources.icons.Delete + +/** + * Each profile row lands straight on the one step it is about: [AppRoute.UpdateUserProfile] walks + * name, username, then photo, and these are single-step edits. + */ +internal data object ChangeDisplayName : FullMenuItem() { + override val icon: Painter + @Composable get() = rememberVectorPainter(Icons.Outlined.Badge) + override val name: String + @Composable get() = stringResource(CoreR.string.title_displayName) + override val action: SettingsViewModel.Event = + SettingsViewModel.Event.OnEditProfile(UpdateProfileStep.Name(DisplayNameSource.MyAccount)) +} + +/** + * Shown only once a handle is claimed. Claiming the first one belongs to the You tab's card, which + * carries the minimum-balance gate; an account holding a handle has already cleared it. + */ +internal data object ChangeUsername : FullMenuItem() { + override val icon: Painter + @Composable get() = rememberVectorPainter(Icons.Outlined.AlternateEmail) + override val name: String + @Composable get() = stringResource(CoreR.string.title_username) + override val action: SettingsViewModel.Event = SettingsViewModel.Event.OnEditProfile(UpdateProfileStep.Username) +} + +internal data object ProfilePicture : FullMenuItem() { + override val icon: Painter + @Composable get() = painterResource(CoreR.drawable.ic_profile_picture) + override val name: String + @Composable get() = stringResource(CoreR.string.title_profilePicture) + override val action: SettingsViewModel.Event = SettingsViewModel.Event.OnEditProfile(UpdateProfileStep.Photo) +} + +internal data object MinimumTip : FullMenuItem() { + override val icon: Painter + @Composable get() = painterResource(CoreR.drawable.ic_coins) + override val name: String + @Composable get() = stringResource(CoreR.string.title_minimumToChat) + override val action: SettingsViewModel.Event = SettingsViewModel.Event.OnEditProfile(UpdateProfileStep.MinimumTip) +} + +internal data object AccessKey : FullMenuItem() { + override val icon: Painter + @Composable get() = painterResource(CoreR.drawable.ic_hardware_security_key) + override val name: String + @Composable get() = stringResource(CoreR.string.title_accessKey) + override val action: SettingsViewModel.Event = SettingsViewModel.Event.OnAccessKeyClicked +} + +/** + * A toggle, not a destination — the screen renders a switch in its trailing slot and routes the tap + * through a biometric prompt. Its [action] is what a row tap dispatches, same as the switch. + */ +internal data object RequireBiometrics : FullMenuItem() { + override val icon: Painter + @Composable get() = painterResource(R.drawable.ic_biometrics) + override val name: String + @Composable get() = stringResource(CoreR.string.title_requireBiometrics) + override val action: SettingsViewModel.Event = SettingsViewModel.Event.OnBiometricsToggled +} + +internal data object Blocklist : FullMenuItem() { + override val icon: Painter + @Composable get() = rememberVectorPainter(Icons.Outlined.Block) + override val name: String + @Composable get() = stringResource(R.string.title_blocklist) + override val action: SettingsViewModel.Event = SettingsViewModel.Event.OpenScreen(AppRoute.Menu.Blocklist) +} + +internal data object BillCustomizer : FullMenuItem() { + override val icon: Painter + @Composable get() = rememberVectorPainter(Icons.Outlined.Palette) + override val name: String + @Composable get() = stringResource(CoreR.string.title_billCustomizer) + override val action: SettingsViewModel.Event = SettingsViewModel.Event.OpenBillPlayground +} + +internal data object DeviceLogs : FullMenuItem() { + override val icon: Painter + @Composable get() = rememberVectorPainter(Icons.Outlined.Description) + override val name: String + @Composable get() = stringResource(CoreR.string.title_deviceLogs) + override val action: SettingsViewModel.Event = SettingsViewModel.Event.OpenScreen(AppRoute.Menu.DeviceLogs) +} + +internal data object BetaFlags : FullMenuItem() { + override val icon: Painter + @Composable get() = rememberVectorPainter(Icons.Filled.Science) + override val name: String + @Composable get() = stringResource(CoreR.string.title_betaFlags) + override val action: SettingsViewModel.Event = SettingsViewModel.Event.OpenScreen(AppRoute.Menu.Lab()) +} + +/** Staff/beta only; the switcher is still a beta tool, so it carries the beta badge. */ +internal data object SwitchAccount : StaffMenuItem() { + override val icon: Painter + @Composable get() = painterResource(CoreR.drawable.ic_menu_switchaccounts) + override val name: String + @Composable get() = stringResource(CoreR.string.title_switchAccounts) + override val action: SettingsViewModel.Event = SettingsViewModel.Event.OpenScreen(AppRoute.Menu.AccountSelection) +} + +/** + * Staff/beta only: the full account screen — contact methods, public key and the rest of the + * account's identifiers. + */ +internal data object AccountInfo : StaffMenuItem() { + override val icon: Painter + @Composable get() = rememberVectorPainter(Icons.Default.ContactMail) + override val name: String + @Composable get() = stringResource(CoreR.string.title_sectionAccountInfo) + override val action: SettingsViewModel.Event = SettingsViewModel.Event.OpenScreen(AppRoute.Menu.UserProfile) +} + +internal data object LogOut : FullMenuItem() { + override val icon: Painter + @Composable get() = painterResource(CoreR.drawable.ic_menu_logout) + override val name: String + @Composable get() = stringResource(CoreR.string.action_logout) + override val action: SettingsViewModel.Event = SettingsViewModel.Event.OnLogOutClicked +} + +internal data object DeleteAccount : FullMenuItem() { + override val icon: Painter + @Composable get() = rememberVectorPainter(ImageVector.Delete) + override val name: String + @Composable get() = stringResource(CoreR.string.action_deleteAccount) + override val action: SettingsViewModel.Event = SettingsViewModel.Event.OnDeleteAccountClicked +} diff --git a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/settings/SettingsScreenContent.kt b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/settings/SettingsScreenContent.kt new file mode 100644 index 0000000000..60ff9d40ce --- /dev/null +++ b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/settings/SettingsScreenContent.kt @@ -0,0 +1,134 @@ +package com.flipcash.app.myaccount.internal.settings + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flipcash.core.R +import com.getcode.libs.biometrics.Biometrics +import com.getcode.theme.CodeTheme +import com.getcode.ui.components.BetaIndicator +import com.getcode.ui.components.ListItem +import com.getcode.ui.components.ListItemDefaults +import com.getcode.ui.components.text.SectionHeader +import com.getcode.ui.core.noRippleClickable +import kotlinx.coroutines.launch + +@Composable +internal fun SettingsScreen(viewModel: SettingsViewModel) { + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + + SettingsScreenContent(state = state, dispatch = viewModel::dispatchEvent) +} + +@Composable +private fun SettingsScreenContent( + state: SettingsViewModel.State, + dispatch: (SettingsViewModel.Event) -> Unit, +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + + // Flipping the biometrics requirement has to be authenticated by the biometrics themselves, + // so the row routes through a prompt before the toggle is dispatched. The switch is display + // only; tapping anywhere on the row (the switch included) runs this. + val toggleBiometrics = { + if (state.biometricsAvailable) { + scope.launch { + Biometrics.prompt(context, delay = 300) + .onSuccess { dispatch(SettingsViewModel.Event.OnBiometricsToggled) } + } + } + Unit + } + + LazyColumn(modifier = Modifier.fillMaxSize()) { + state.sections.forEach { section -> + item(key = section.title, contentType = "header") { + SectionHeader( + title = stringResource(section.title).uppercase(), + modifier = Modifier.padding(horizontal = CodeTheme.dimens.grid.x5), + ) + } + items(section.items, key = { it.id }, contentType = { "row" }) { item -> + val isBiometrics = item == RequireBiometrics + ListItem( + headline = item.name, + icon = item.icon, + modifier = Modifier, + // Only the biometrics row can be inert, and only when the hardware has nothing + // enrolled. + enabled = !isBiometrics || state.biometricsAvailable, + supportingText = state.biometricsDescription + ?.takeIf { isBiometrics } + ?.let { stringResource(it) }, + onClick = { if (isBiometrics) toggleBiometrics() else dispatch(item.action) }, + endSlot = { + if (item.showBetaIndicator) { + BetaIndicator() + Spacer(Modifier.width(CodeTheme.dimens.grid.x2)) + } + if (isBiometrics) { + ListItemDefaults.Toggle( + checked = state.biometricsRequired, + enabled = state.biometricsAvailable, + ) + } else { + ListItemDefaults.Chevron() + } + }, + ) + } + } + + item(key = "version_footer", contentType = "footer") { + VersionFooter( + state = state, + onClick = { dispatch(SettingsViewModel.Event.OnVersionInfoClicked) }, + modifier = Modifier.padding( + top = CodeTheme.dimens.grid.x6, + bottom = CodeTheme.dimens.grid.x3, + ), + ) + } + } +} + +/** The "Version … • Build …" footer; its repeated tap toggles beta access (see the ViewModel). */ +@Composable +private fun VersionFooter( + state: SettingsViewModel.State, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Box(modifier = modifier.fillMaxWidth()) { + Text( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.Center) + .noRippleClickable { onClick() }, + text = stringResource( + R.string.subtitle_appVersionInfoFooter, + state.appVersionInfo.versionName, + state.appVersionInfo.versionCode, + state.releaseTrack, + ), + color = CodeTheme.colors.textSecondary, + style = CodeTheme.typography.textSmall.copy(textAlign = TextAlign.Center), + ) + } +} diff --git a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/settings/SettingsViewModel.kt b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/settings/SettingsViewModel.kt new file mode 100644 index 0000000000..6b3acb3184 --- /dev/null +++ b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/settings/SettingsViewModel.kt @@ -0,0 +1,377 @@ +package com.flipcash.app.myaccount.internal.settings + +import androidx.annotation.StringRes +import androidx.lifecycle.viewModelScope +import com.flipcash.app.appsettings.AppSettingValue +import com.flipcash.app.appsettings.AppSettingsCoordinator +import com.flipcash.app.auth.AuthManager +import com.flipcash.app.core.AppRoute +import com.flipcash.app.core.android.VersionInfo +import com.flipcash.app.core.userprofile.UpdateProfileStep +import com.flipcash.app.featureflags.BetaFeature +import com.flipcash.app.featureflags.FeatureFlagController +import com.flipcash.app.menu.MenuItem +import com.flipcash.app.menu.StaffMenuItem +import com.flipcash.app.updates.ReleaseStage +import com.flipcash.app.updates.ReleaseStageProvider +import com.flipcash.app.userflags.UserFlagsCoordinator +import com.flipcash.core.R +import com.flipcash.features.myaccount.BuildConfig +import com.flipcash.libs.coroutines.DispatcherProvider +import com.flipcash.services.user.UserManager +import com.getcode.manager.BottomBarAction +import com.getcode.manager.BottomBarManager +import com.getcode.util.resources.ResourceHelper +import com.getcode.view.BaseViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** A headed group of rows. Rendered in declaration order, and dropped when it has no rows. */ +internal data class SettingsSection( + @StringRes val title: Int, + val items: List>, +) + +@HiltViewModel +internal class SettingsViewModel @Inject constructor( + private val appSettings: AppSettingsCoordinator, + private val featureFlags: FeatureFlagController, + userFlags: UserFlagsCoordinator, + userManager: UserManager, + versionInfo: VersionInfo, + releaseStageProvider: ReleaseStageProvider, + private val resources: ResourceHelper, + private val authManager: AuthManager, + dispatchers: DispatcherProvider, +) : BaseViewModel( + initialState = State(), + updateStateForEvent = updateStateForEvent, + defaultDispatcher = dispatchers.Default, +) { + internal data class State( + val biometricsRequired: Boolean = false, + // Biometrics aren't offerable on every device: the row is hidden outright when the hardware + // isn't there, and shown-but-disabled when the hardware exists with nothing enrolled. + val biometricsSupported: Boolean = true, + val biometricsAvailable: Boolean = true, + // Why the row can't act, when it can't — e.g. the hardware is there with nothing enrolled. + @StringRes val biometricsDescription: Int? = null, + // Staff, or the version-footer override. Gates the Beta-badged rows. + val betaUnlocked: Boolean = false, + // Whether the version-footer override specifically is on; staff don't count toward the + // tap-to-unlock easter egg. + val unlockedBetaFeaturesManually: Boolean = false, + // Whether the account holds a handle. Changing one presupposes having one. + val usernameClaimed: Boolean = false, + val flags: List = emptyList(), + val versionTapCount: Int = 0, + val appVersionInfo: VersionInfo = VersionInfo(), + val releaseTrack: String = "", + // Conditional rows — staff, and the handle — stay out until their real state loads, so they + // never flash in for an account that shouldn't see them. + val sections: List = buildSections( + biometricsSupported = true, + betaUnlocked = false, + usernameClaimed = false, + flags = emptyList(), + ), + ) + + internal sealed interface Event { + data class OnBetaFeaturesUnlocked( + val unlocked: Boolean, + val manual: Boolean = unlocked, + ) : Event + data class OnFeatureFlagsUpdated(val flags: List) : Event + data class OnBiometricsSettingChanged( + val required: Boolean, + val supported: Boolean, + val available: Boolean, + @StringRes val description: Int? = null, + ) : Event + /** Dispatched only after the screen's biometric prompt succeeds. */ + data object OnBiometricsToggled : Event + data class OnUsernameClaimChanged(val claimed: Boolean) : Event + data class OnAppVersionUpdated(val versionInfo: VersionInfo) : Event + data class OnReleaseTrackDetermined(val track: String) : Event + + data class OnEditProfile(val step: UpdateProfileStep) : Event + data class OpenScreen(val screen: AppRoute) : Event + data object OpenBillPlayground : Event + data object OnAccessKeyClicked : Event + data object OnViewAccessKey : Event + data object OnLogOutClicked : Event + data object OnLoggedOutCompletely : Event + data object OnDeleteAccountClicked : Event + data object OnAccountDeleted : Event + + data object OnVersionInfoClicked : Event + /** + * A developer-mode unlock message (the tap countdown, then unlocked). The screen floats it + * over the version row without taking taps, so the taps keep counting through it. + */ + data class ShowDevModeToast(val message: String) : Event + } + + init { + dispatchEvent(Event.OnAppVersionUpdated(versionInfo)) + + combine( + featureFlags.observeOverride(), + userFlags.resolvedFlags.map { it.isStaff.effectiveValue }, + ) { override, isStaff -> Event.OnBetaFeaturesUnlocked(override || isStaff, manual = override) } + .onEach { dispatchEvent(it) } + .launchIn(viewModelScope) + + featureFlags.observe() + .onEach { dispatchEvent(Event.OnFeatureFlagsUpdated(it)) } + .launchIn(viewModelScope) + + userManager.state + .map { it.userProfile?.username } + .map { username -> !username.isNullOrBlank() } + .distinctUntilChanged() + .onEach { dispatchEvent(Event.OnUsernameClaimChanged(it)) } + .launchIn(viewModelScope) + + appSettings.settings() + .map { items -> items.find { it.setting.type == AppSettingValue.BiometricsRequired } } + .onEach { item -> + item ?: return@onEach + dispatchEvent( + Event.OnBiometricsSettingChanged( + required = item.setting.enabled, + supported = item.visible, + available = item.available, + description = item.description, + ) + ) + }.launchIn(viewModelScope) + + viewModelScope.launch { + val resolvedStage = releaseStageProvider.resolvedStage + val label = when { + BuildConfig.DEBUG -> "development" + resolvedStage == null || resolvedStage == ReleaseStage.Production -> null + else -> resolvedStage.name.lowercase() + } + dispatchEvent(Event.OnReleaseTrackDetermined(if (label != null) " • $label" else "")) + } + + eventFlow + .filterIsInstance() + .onEach { + appSettings.update( + AppSettingValue.BiometricsRequired, + !stateFlow.value.biometricsRequired, + ) + }.launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .onEach { + if (stateFlow.value.unlockedBetaFeaturesManually) { + if (stateFlow.value.versionTapCount - TAP_THRESHOLD > COUNTDOWN_START) { + dispatchEvent(Event.ShowDevModeToast(resources.getString(R.string.toast_betaOverrideAlready))) + } + return@onEach + } + val remaining = TAP_THRESHOLD - stateFlow.value.versionTapCount + 1 + when { + remaining <= 0 -> { + featureFlags.enableBetaFeatures() + dispatchEvent(Event.ShowDevModeToast(resources.getString(R.string.toast_betaOverrideEnabled))) + } + remaining <= COUNTDOWN_START -> { + dispatchEvent( + Event.ShowDevModeToast( + resources.getQuantityString(R.plurals.toast_betaOverrideCountdown, remaining, remaining) + ) + ) + } + } + }.launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .onEach { + BottomBarManager.showAlert( + title = resources.getString(R.string.prompt_title_viewAccessKey), + message = resources.getString(R.string.prompt_description_viewAccessKey), + showScrim = true, + showCancel = true, + actions = listOf( + BottomBarAction( + text = resources.getString(R.string.action_viewAccessKey), + onClick = { dispatchEvent(Event.OnViewAccessKey) } + ) + ), + ) + }.launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .onEach { + BottomBarManager.showAlert( + title = resources.getString(R.string.prompt_title_logout), + message = resources.getString(R.string.prompt_description_logout), + actions = listOf( + BottomBarAction(resources.getString(R.string.action_logout)) { + viewModelScope.launch { + delay(150) // wait for dismiss + authManager.logout() + .onSuccess { dispatchEvent(Event.OnLoggedOutCompletely) } + .onFailure { + BottomBarManager.showError( + title = resources.getString(R.string.error_title_failedToLogOut), + message = resources.getString(R.string.error_description_failedToLogOut), + ) + } + } + }, + ), + showCancel = true, + ) + }.launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .onEach { + BottomBarManager.showAlert( + title = resources.getString(R.string.prompt_title_deleteAccount), + message = resources.getString(R.string.prompt_description_deleteAccount), + actions = listOf( + BottomBarAction(resources.getString(R.string.action_deleteAccount)) { + viewModelScope.launch { + delay(150) // wait for dismiss + authManager.deleteAndLogout() + .onSuccess { dispatchEvent(Event.OnAccountDeleted) } + .onFailure { + BottomBarManager.showError( + title = resources.getString(R.string.error_title_failedToDeleteAccount), + message = resources.getString(R.string.error_description_failedToDeleteAccount), + ) + } + } + } + ), + showCancel = true, + ) + }.launchIn(viewModelScope) + } + + internal companion object { + private const val TAP_THRESHOLD = 6 + private const val COUNTDOWN_START = 3 + + /** + * Biometrics drops out on hardware that can't offer it; Beta-badged rows need the beta + * unlock; changing a handle needs one to already be claimed; flag-gated rows additionally + * need their flag switched on server-side. Bill Customizer stays out, as it did on the + * Advanced screen. + */ + internal fun buildSections( + biometricsSupported: Boolean, + betaUnlocked: Boolean, + usernameClaimed: Boolean, + flags: List, + ): List { + val all = listOf( + SettingsSection( + R.string.title_settingsSectionProfile, + listOf(ChangeDisplayName, ChangeUsername, ProfilePicture, MinimumTip), + ), + SettingsSection( + R.string.title_settingsSectionSecurity, + listOf(AccessKey, RequireBiometrics), + ), + SettingsSection(R.string.title_settingsSectionPrivacy, listOf(Blocklist)), + SettingsSection( + R.string.title_advancedFeatures, + listOf(/* BillCustomizer, */ DeviceLogs, BetaFlags, SwitchAccount), + ), + SettingsSection( + R.string.title_settingsSectionAccount, + listOf(AccountInfo, LogOut, DeleteAccount), + ), + ) + return all + .map { section -> + section.copy( + items = section.items + .filterNot { it == RequireBiometrics && !biometricsSupported } + .filterNot { it == ChangeUsername && !usernameClaimed } + .filter { it !is StaffMenuItem<*> || betaUnlocked } + .filter { item -> + val flag = item.featureFlag ?: return@filter true + flags.find { it.flag.key == flag.key }?.enabled == true + } + ) + } + .filter { it.items.isNotEmpty() } + } + + private fun State.rebuilt( + biometricsSupported: Boolean = this.biometricsSupported, + betaUnlocked: Boolean = this.betaUnlocked, + usernameClaimed: Boolean = this.usernameClaimed, + flags: List = this.flags, + ) = copy( + biometricsSupported = biometricsSupported, + betaUnlocked = betaUnlocked, + usernameClaimed = usernameClaimed, + flags = flags, + sections = buildSections(biometricsSupported, betaUnlocked, usernameClaimed, flags), + ) + + val updateStateForEvent: (Event) -> ((State) -> State) = { event -> + when (event) { + is Event.OnBetaFeaturesUnlocked -> { state -> + state.rebuilt(betaUnlocked = event.unlocked) + .copy(unlockedBetaFeaturesManually = event.manual) + } + + is Event.OnFeatureFlagsUpdated -> { state -> state.rebuilt(flags = event.flags) } + + is Event.OnUsernameClaimChanged -> { state -> + state.rebuilt(usernameClaimed = event.claimed) + } + + is Event.OnBiometricsSettingChanged -> { state -> + state.rebuilt(biometricsSupported = event.supported).copy( + biometricsRequired = event.required, + biometricsAvailable = event.available, + biometricsDescription = event.description, + ) + } + + Event.OnVersionInfoClicked -> { state -> + state.copy(versionTapCount = state.versionTapCount + 1) + } + + is Event.OnAppVersionUpdated -> { state -> state.copy(appVersionInfo = event.versionInfo) } + is Event.OnReleaseTrackDetermined -> { state -> state.copy(releaseTrack = event.track) } + + Event.OnBiometricsToggled, + is Event.OnEditProfile, + is Event.OpenScreen, + Event.OpenBillPlayground, + Event.OnAccessKeyClicked, + Event.OnViewAccessKey, + Event.OnLogOutClicked, + Event.OnLoggedOutCompletely, + Event.OnDeleteAccountClicked, + Event.OnAccountDeleted, + is Event.ShowDevModeToast -> { state -> state } + } + } + } +} diff --git a/apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/MyAccountScreenViewModelStateTest.kt b/apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/MyAccountScreenViewModelStateTest.kt deleted file mode 100644 index ee5fcd2bfb..0000000000 --- a/apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/MyAccountScreenViewModelStateTest.kt +++ /dev/null @@ -1,294 +0,0 @@ -package com.flipcash.app.myaccount.internal - -import com.flipcash.app.myaccount.internal.myaccount.Blocklist -import com.flipcash.app.myaccount.internal.myaccount.ChangeDisplayName -import com.flipcash.app.myaccount.internal.myaccount.ChangeUsername -import com.flipcash.app.myaccount.internal.myaccount.MinimumTip -import com.flipcash.app.myaccount.internal.myaccount.MyAccountScreenViewModel -import com.flipcash.app.myaccount.internal.myaccount.ProfilePicture -import com.flipcash.app.myaccount.internal.myaccount.RequireBiometrics -import com.flipcash.app.myaccount.internal.myaccount.UserProfile -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertTrue - -class MyAccountScreenViewModelStateTest { - - private val reduce = MyAccountScreenViewModel.Companion.updateStateForEvent - - private fun claimed(state: MyAccountScreenViewModel.State) = - reduce(MyAccountScreenViewModel.Event.OnUsernameClaimChanged(claimed = true))(state) - - @Test - fun `default state lists the display name, profile picture, minimum tip, biometrics and blocklist`() { - val state = MyAccountScreenViewModel.State() - assertEquals( - listOf(ChangeDisplayName, ProfilePicture, MinimumTip, RequireBiometrics, Blocklist), - state.items, - ) - assertFalse(state.biometricsRequired) - } - - @Test - fun `the profile picture row carries no condition`() { - val noBiometrics = reduce( - MyAccountScreenViewModel.Event.OnBiometricsSettingChanged( - required = false, - supported = false, - available = false, - ) - )(MyAccountScreenViewModel.State()) - - assertTrue(noBiometrics.items.any { it is ProfilePicture }) - assertTrue(claimed(noBiometrics).items.any { it is ProfilePicture }) - } - - @Test - fun `changing the username is offered only once a handle is claimed`() { - val unclaimed = MyAccountScreenViewModel.State() - assertFalse(unclaimed.usernameClaimed) - assertFalse(unclaimed.items.any { it is ChangeUsername }) - - val withHandle = claimed(unclaimed) - - assertTrue(withHandle.usernameClaimed) - assertEquals( - listOf( - ChangeDisplayName, - ChangeUsername, - ProfilePicture, - MinimumTip, - RequireBiometrics, - Blocklist, - ), - withHandle.items, - ) - } - - @Test - fun `losing the handle takes the username row back out`() { - val dropped = reduce( - MyAccountScreenViewModel.Event.OnUsernameClaimChanged(claimed = false) - )(claimed(MyAccountScreenViewModel.State())) - - assertFalse(dropped.usernameClaimed) - assertFalse(dropped.items.any { it is ChangeUsername }) - } - - @Test - fun `biometrics changes keep a claimed username row visible`() { - val updated = reduce( - MyAccountScreenViewModel.Event.OnBiometricsSettingChanged( - required = true, - supported = true, - available = true, - ) - )(claimed(MyAccountScreenViewModel.State())) - - assertTrue(updated.items.any { it is ChangeUsername }) - } - - @Test - fun `unlocking beta keeps a claimed username row visible`() { - val updated = reduce( - MyAccountScreenViewModel.Event.OnBetaFeaturesUnlocked(unlocked = true) - )(claimed(MyAccountScreenViewModel.State())) - - assertTrue(updated.items.any { it is ChangeUsername }) - assertTrue(updated.items.any { it is UserProfile }) - } - - @Test - fun `claiming a handle leaves the other rows' conditions alone`() { - val noBiometrics = reduce( - MyAccountScreenViewModel.Event.OnBiometricsSettingChanged( - required = false, - supported = false, - available = false, - ) - )(MyAccountScreenViewModel.State()) - - val withHandle = claimed(noBiometrics) - - assertTrue(withHandle.items.any { it is ChangeUsername }) - assertFalse(withHandle.items.any { it is RequireBiometrics }) - assertFalse(withHandle.items.any { it is UserProfile }) - } - - @Test - fun `changing the display name is offered without the beta unlock`() { - val locked = MyAccountScreenViewModel.State() - assertTrue(locked.items.any { it is ChangeDisplayName }) - - val noBiometrics = reduce( - MyAccountScreenViewModel.Event.OnBiometricsSettingChanged( - required = false, - supported = false, - available = false, - ) - )(locked) - - assertTrue(noBiometrics.items.any { it is ChangeDisplayName }) - } - - @Test - fun `unsupported biometrics hides the row`() { - val updated = reduce( - MyAccountScreenViewModel.Event.OnBiometricsSettingChanged( - required = false, - supported = false, - available = false, - ) - )(MyAccountScreenViewModel.State()) - - assertFalse(updated.items.any { it is RequireBiometrics }) - assertTrue(updated.items.any { it is Blocklist }) - } - - @Test - fun `enrolled biometrics keeps the row and mirrors the setting`() { - val updated = reduce( - MyAccountScreenViewModel.Event.OnBiometricsSettingChanged( - required = true, - supported = true, - available = true, - ) - )(MyAccountScreenViewModel.State()) - - assertTrue(updated.items.any { it is RequireBiometrics }) - assertTrue(updated.biometricsRequired) - assertTrue(updated.biometricsAvailable) - } - - @Test - fun `an unlocked user profile leads the list`() { - val unlocked = reduce( - MyAccountScreenViewModel.Event.OnBetaFeaturesUnlocked(unlocked = true) - )(claimed(MyAccountScreenViewModel.State())) - - assertEquals( - listOf( - UserProfile, - ChangeDisplayName, - ChangeUsername, - ProfilePicture, - MinimumTip, - RequireBiometrics, - Blocklist, - ), - unlocked.items, - ) - } - - @Test - fun `user profile stays hidden until beta features unlock`() { - val locked = MyAccountScreenViewModel.State() - assertFalse(locked.items.any { it is UserProfile }) - - val unlocked = reduce( - MyAccountScreenViewModel.Event.OnBetaFeaturesUnlocked(unlocked = true) - )(locked) - - assertTrue(unlocked.items.any { it is UserProfile }) - assertTrue(unlocked.items.any { it is RequireBiometrics }) - } - - @Test - fun `unlocking beta keeps an unsupported biometrics row hidden`() { - val noBiometrics = reduce( - MyAccountScreenViewModel.Event.OnBiometricsSettingChanged( - required = false, - supported = false, - available = false, - ) - )(MyAccountScreenViewModel.State()) - - val unlocked = reduce( - MyAccountScreenViewModel.Event.OnBetaFeaturesUnlocked(unlocked = true) - )(noBiometrics) - - assertFalse(unlocked.items.any { it is RequireBiometrics }) - assertTrue(unlocked.items.any { it is UserProfile }) - } - - @Test - fun `biometrics changes keep an unlocked user profile visible`() { - val unlocked = reduce( - MyAccountScreenViewModel.Event.OnBetaFeaturesUnlocked(unlocked = true) - )(MyAccountScreenViewModel.State()) - - val updated = reduce( - MyAccountScreenViewModel.Event.OnBiometricsSettingChanged( - required = true, - supported = true, - available = true, - ) - )(unlocked) - - assertTrue(updated.items.any { it is UserProfile }) - } - - @Test - fun `unavailable biometrics carries the explanation through`() { - val updated = reduce( - MyAccountScreenViewModel.Event.OnBiometricsSettingChanged( - required = false, - supported = true, - available = false, - description = 42, - ) - )(MyAccountScreenViewModel.State()) - - assertTrue(updated.items.any { it is RequireBiometrics }) - assertFalse(updated.biometricsAvailable) - assertEquals(42, updated.biometricsDescription) - } - - @Test - fun `enrolling biometrics clears the explanation`() { - val unavailable = reduce( - MyAccountScreenViewModel.Event.OnBiometricsSettingChanged( - required = false, - supported = true, - available = false, - description = 42, - ) - )(MyAccountScreenViewModel.State()) - - val enrolled = reduce( - MyAccountScreenViewModel.Event.OnBiometricsSettingChanged( - required = false, - supported = true, - available = true, - description = null, - ) - )(unavailable) - - assertTrue(enrolled.biometricsAvailable) - assertEquals(null, enrolled.biometricsDescription) - } - - @Test - fun `no-op events return state unchanged`() { - val state = MyAccountScreenViewModel.State(biometricsRequired = true) - val noOpEvents = listOf( - MyAccountScreenViewModel.Event.OnBiometricsToggled, - MyAccountScreenViewModel.Event.OnChangeDisplayNameClicked, - MyAccountScreenViewModel.Event.OnEditDisplayName, - MyAccountScreenViewModel.Event.OnChangeUsernameClicked, - MyAccountScreenViewModel.Event.OnEditUsername, - MyAccountScreenViewModel.Event.OnProfilePictureClicked, - MyAccountScreenViewModel.Event.OnEditProfilePicture, - MyAccountScreenViewModel.Event.OnMinimumTipClicked, - MyAccountScreenViewModel.Event.OnEditMinimumTip, - MyAccountScreenViewModel.Event.OnContactMethodsClicked, - MyAccountScreenViewModel.Event.OnViewUserProfile, - MyAccountScreenViewModel.Event.OnBlocklistClicked, - MyAccountScreenViewModel.Event.OnViewBlocklist, - ) - noOpEvents.forEach { event -> - assertEquals(state, reduce(event)(state), "Event $event should be no-op") - } - } -} diff --git a/apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/SettingsViewModelTest.kt b/apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/SettingsViewModelTest.kt new file mode 100644 index 0000000000..342e31af94 --- /dev/null +++ b/apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/SettingsViewModelTest.kt @@ -0,0 +1,241 @@ +package com.flipcash.app.myaccount.internal + +import com.flipcash.app.appsettings.AppSettingsCoordinator +import com.flipcash.app.auth.AuthManager +import com.flipcash.app.core.MainCoroutineRule +import com.flipcash.app.core.android.VersionInfo +import com.flipcash.app.core.dispatchers.TestDispatchers +import com.flipcash.app.featureflags.FeatureFlagController +import com.flipcash.app.myaccount.internal.settings.AccessKey +import com.flipcash.app.myaccount.internal.settings.AccountInfo +import com.flipcash.app.myaccount.internal.settings.BetaFlags +import com.flipcash.app.myaccount.internal.settings.Blocklist +import com.flipcash.app.myaccount.internal.settings.ChangeDisplayName +import com.flipcash.app.myaccount.internal.settings.ChangeUsername +import com.flipcash.app.myaccount.internal.settings.DeleteAccount +import com.flipcash.app.myaccount.internal.settings.DeviceLogs +import com.flipcash.app.myaccount.internal.settings.LogOut +import com.flipcash.app.myaccount.internal.settings.MinimumTip +import com.flipcash.app.myaccount.internal.settings.ProfilePicture +import com.flipcash.app.myaccount.internal.settings.RequireBiometrics +import com.flipcash.app.myaccount.internal.settings.SettingsViewModel +import com.flipcash.app.myaccount.internal.settings.SwitchAccount +import com.flipcash.app.updates.ReleaseStageProvider +import com.flipcash.app.userflags.FieldOverride +import com.flipcash.app.userflags.ResolvedFlag +import com.flipcash.app.userflags.ResolvedUserFlags +import com.flipcash.app.userflags.UserFlagsCoordinator +import com.flipcash.core.R +import com.flipcash.services.user.UserManager +import com.getcode.util.resources.FakeResourceHelper +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + + +@OptIn(ExperimentalCoroutinesApi::class) +class SettingsViewModelTest { + + @get:Rule + var mainCoroutineRule = MainCoroutineRule(UnconfinedTestDispatcher()) + + private val reduce = SettingsViewModel.updateStateForEvent + + private fun SettingsViewModel.State.items() = sections.flatMap { it.items } + private fun SettingsViewModel.State.titles() = sections.map { it.title } + + private fun SettingsViewModel.State.after(vararg events: SettingsViewModel.Event): SettingsViewModel.State = + events.fold(this) { state, event -> reduce(event)(state) } + + // region sections and conditional rows + + @Test + fun `default state lists every section top to bottom with no conditional rows`() { + val state = SettingsViewModel.State() + assertEquals( + listOf( + R.string.title_settingsSectionProfile, + R.string.title_settingsSectionSecurity, + R.string.title_settingsSectionPrivacy, + R.string.title_advancedFeatures, + R.string.title_settingsSectionAccount, + ), + state.titles(), + ) + assertEquals( + listOf( + ChangeDisplayName, ProfilePicture, MinimumTip, + AccessKey, RequireBiometrics, + Blocklist, + DeviceLogs, BetaFlags, + LogOut, DeleteAccount, + ), + state.items(), + ) + } + + @Test + fun `staff rows are absent without beta or staff`() { + val items = SettingsViewModel.State().items() + assertFalse(SwitchAccount in items) + assertFalse(AccountInfo in items) + } + + @Test + fun `staff rows appear once beta unlocks and leave again when it locks`() { + val unlocked = SettingsViewModel.State().after(SettingsViewModel.Event.OnBetaFeaturesUnlocked(true)) + assertTrue(SwitchAccount in unlocked.items()) + assertTrue(AccountInfo in unlocked.items()) + + val relocked = unlocked.after(SettingsViewModel.Event.OnBetaFeaturesUnlocked(false)) + assertFalse(SwitchAccount in relocked.items()) + assertFalse(AccountInfo in relocked.items()) + } + + @Test + fun `account info sits first in the account section`() { + val state = SettingsViewModel.State().after(SettingsViewModel.Event.OnBetaFeaturesUnlocked(true)) + val account = state.sections.last { it.title == R.string.title_settingsSectionAccount } + assertEquals(listOf(AccountInfo, LogOut, DeleteAccount), account.items) + } + + @Test + fun `username row is absent without a claimed handle`() { + assertFalse(ChangeUsername in SettingsViewModel.State().items()) + } + + @Test + fun `username row follows the claimed handle and sits after the display name`() { + val state = SettingsViewModel.State().after(SettingsViewModel.Event.OnUsernameClaimChanged(true)) + val profile = state.sections.first().items + assertEquals(listOf(ChangeDisplayName, ChangeUsername, ProfilePicture, MinimumTip), profile) + assertFalse(ChangeUsername in state.after(SettingsViewModel.Event.OnUsernameClaimChanged(false)).items()) + } + + @Test + fun `unsupported biometrics hides the row`() { + val state = SettingsViewModel.State().after( + SettingsViewModel.Event.OnBiometricsSettingChanged(required = false, supported = false, available = false), + ) + assertFalse(RequireBiometrics in state.items()) + assertTrue(AccessKey in state.items()) + } + + @Test + fun `later events keep earlier conditions`() { + val state = SettingsViewModel.State().after( + SettingsViewModel.Event.OnBiometricsSettingChanged(required = true, supported = false, available = false), + SettingsViewModel.Event.OnUsernameClaimChanged(true), + SettingsViewModel.Event.OnBetaFeaturesUnlocked(true), + ) + assertFalse(RequireBiometrics in state.items()) + assertTrue(ChangeUsername in state.items()) + assertTrue(SwitchAccount in state.items()) + } + + @Test + fun `unavailable biometrics carries the explanation and enrolling clears it`() { + val blocked = SettingsViewModel.State().after( + SettingsViewModel.Event.OnBiometricsSettingChanged( + required = false, supported = true, available = false, description = 123, + ), + ) + assertEquals(123, blocked.biometricsDescription) + assertTrue(RequireBiometrics in blocked.items()) + + val enrolled = blocked.after( + SettingsViewModel.Event.OnBiometricsSettingChanged(required = true, supported = true, available = true), + ) + assertEquals(null, enrolled.biometricsDescription) + assertTrue(enrolled.biometricsRequired) + } + + @Test + fun `navigation events leave state unchanged`() { + val state = SettingsViewModel.State() + listOf( + SettingsViewModel.Event.OnBiometricsToggled, + SettingsViewModel.Event.OnAccessKeyClicked, + SettingsViewModel.Event.OnLogOutClicked, + SettingsViewModel.Event.OnDeleteAccountClicked, + ).forEach { assertEquals(state, reduce(it)(state)) } + } + + // endregion + + // region version footer + + private val featureFlags = mockk(relaxed = true) + + private fun TestScope.createViewModel(): SettingsViewModel { + val userFlags = mockk(relaxed = true) + val resolved = mockk(relaxed = true) { + every { isStaff } returns ResolvedFlag(serverValue = false, override = FieldOverride.None) + } + every { userFlags.resolvedFlags } returns MutableStateFlow(resolved) + every { featureFlags.observeOverride() } returns MutableStateFlow(false) + every { featureFlags.observe() } returns MutableStateFlow(emptyList()) + + val appSettings = mockk(relaxed = true) + every { appSettings.settings() } returns emptyFlow() + + val userManager = mockk(relaxed = true) + every { userManager.state } returns MutableStateFlow(UserManager.State()) + + return SettingsViewModel( + appSettings = appSettings, + featureFlags = featureFlags, + userFlags = userFlags, + userManager = userManager, + versionInfo = VersionInfo("1.0", 1), + releaseStageProvider = mockk(relaxed = true), + resources = FakeResourceHelper(), + authManager = mockk(relaxed = true), + dispatchers = TestDispatchers(testScheduler), + ) + } + + @Test + fun `version taps reach the threshold and enable beta features exactly once`() = runTest(mainCoroutineRule.dispatcher) { + val vm = createViewModel() + + repeat(6) { + vm.dispatchEvent(SettingsViewModel.Event.OnVersionInfoClicked) + advanceUntilIdle() + } + verify(exactly = 0) { featureFlags.enableBetaFeatures() } + + vm.dispatchEvent(SettingsViewModel.Event.OnVersionInfoClicked) + advanceUntilIdle() + verify(exactly = 1) { featureFlags.enableBetaFeatures() } + assertEquals(7, vm.stateFlow.value.versionTapCount) + } + + @Test + fun `already unlocked by the override means taps never re-enable`() = runTest(mainCoroutineRule.dispatcher) { + val vm = createViewModel() + vm.dispatchEvent(SettingsViewModel.Event.OnBetaFeaturesUnlocked(unlocked = true, manual = true)) + advanceUntilIdle() + + repeat(10) { + vm.dispatchEvent(SettingsViewModel.Event.OnVersionInfoClicked) + advanceUntilIdle() + } + + verify(exactly = 0) { featureFlags.enableBetaFeatures() } + } + + // endregion +} diff --git a/apps/flipcash/shared/router/src/main/kotlin/com/flipcash/app/router/internal/AppRouter.kt b/apps/flipcash/shared/router/src/main/kotlin/com/flipcash/app/router/internal/AppRouter.kt index f3b33f1d86..edc2660020 100644 --- a/apps/flipcash/shared/router/src/main/kotlin/com/flipcash/app/router/internal/AppRouter.kt +++ b/apps/flipcash/shared/router/src/main/kotlin/com/flipcash/app/router/internal/AppRouter.kt @@ -282,9 +282,9 @@ internal class AppRouter( EmailDeeplinkOrigin.MyAccount -> listOf( AppRoute.Tabs.Menu, - AppRoute.Menu.MyAccount + AppRoute.Menu.Settings ) + AppRoute.Verification( - origin = AppRoute.Menu.MyAccount, + origin = AppRoute.Menu.Settings, includePhone = false, email = type.email, emailVerificationCode = type.code diff --git a/apps/flipcash/shared/router/src/test/kotlin/com/flipcash/app/router/internal/AppRouterTest.kt b/apps/flipcash/shared/router/src/test/kotlin/com/flipcash/app/router/internal/AppRouterTest.kt index cfc95380e6..821f61c602 100644 --- a/apps/flipcash/shared/router/src/test/kotlin/com/flipcash/app/router/internal/AppRouterTest.kt +++ b/apps/flipcash/shared/router/src/test/kotlin/com/flipcash/app/router/internal/AppRouterTest.kt @@ -239,7 +239,7 @@ class AppRouterTest { val action = router.dispatch(DeepLink(verifyUrl("myaccount"))) assertIs(action) assertEquals(AppRoute.Tabs.Menu, action.routes[0]) - assertIs(action.routes[1]) + assertIs(action.routes[1]) assertIs(action.routes[2]) } @@ -749,7 +749,7 @@ class AppRouterTest { assertIs(action) assertEquals(3, action.routes.size) assertIs(action.routes[0]) - assertIs(action.routes[1]) + assertIs(action.routes[1]) val verification = action.routes[2] assertIs(verification) assertEquals("test@example.com", verification.email) diff --git a/apps/flipcash/shared/router/src/test/kotlin/com/flipcash/app/router/internal/NavigateToTest.kt b/apps/flipcash/shared/router/src/test/kotlin/com/flipcash/app/router/internal/NavigateToTest.kt index d79877ae90..ad9eec7efb 100644 --- a/apps/flipcash/shared/router/src/test/kotlin/com/flipcash/app/router/internal/NavigateToTest.kt +++ b/apps/flipcash/shared/router/src/test/kotlin/com/flipcash/app/router/internal/NavigateToTest.kt @@ -60,7 +60,7 @@ class NavigateToTest { AppRoute.Main.Sheet(AppRoute.Sheets.ActivityHistory), ) - navigator.navigateAll(listOf(AppRoute.Menu.MyAccount), options = quietOptions) + navigator.navigateAll(listOf(AppRoute.Menu.Settings), options = quietOptions) assertNull(navigator.pendingSheetDismiss) } @@ -195,7 +195,7 @@ class NavigateToTest { navigator.navigateAll(listOf(AppRoute.Sheets.ActivityHistory), options = quietOptions) // Simulate: a route is pushed during the dismiss animation - navigator.backStack.add(AppRoute.Menu.MyAccount) + navigator.backStack.add(AppRoute.Menu.Settings) // onBack() removes the last entry (MyAccount), NOT the old sheet navigator.backStack.removeAt(navigator.backStack.lastIndex) // Old sheet is still on the backstack diff --git a/apps/flipcash/shared/router/src/test/kotlin/com/flipcash/app/router/internal/ResolveRoutesTest.kt b/apps/flipcash/shared/router/src/test/kotlin/com/flipcash/app/router/internal/ResolveRoutesTest.kt index 340f1422e0..287a8a7c32 100644 --- a/apps/flipcash/shared/router/src/test/kotlin/com/flipcash/app/router/internal/ResolveRoutesTest.kt +++ b/apps/flipcash/shared/router/src/test/kotlin/com/flipcash/app/router/internal/ResolveRoutesTest.kt @@ -32,7 +32,7 @@ class ResolveRoutesTest { @Test fun `non-sheet routes pass through unchanged`() { - val routes = listOf(AppRoute.Tabs.Scanner, AppRoute.Menu.MyAccount) + val routes = listOf(AppRoute.Tabs.Scanner, AppRoute.Menu.Settings) assertEquals(routes, resolveRoutes(routes)) } @@ -70,9 +70,9 @@ class ResolveRoutesTest { fun `sheet with multiple inner routes bundles all`() { val routes = listOf( AppRoute.Sheets.ActivityHistory, - AppRoute.Menu.MyAccount, + AppRoute.Menu.Settings, AppRoute.Verification( - origin = AppRoute.Menu.MyAccount, + origin = AppRoute.Menu.Settings, includePhone = false, email = "test@example.com", emailVerificationCode = "123456", @@ -85,7 +85,7 @@ class ResolveRoutesTest { assertIs(sheet) assertEquals(AppRoute.Sheets.ActivityHistory, sheet.initialRoute) assertEquals(2, sheet.innerRoutes.size) - assertIs(sheet.innerRoutes[0]) + assertIs(sheet.innerRoutes[0]) assertIs(sheet.innerRoutes[1]) } @@ -166,9 +166,9 @@ class ResolveRoutesTest { fun `menu tab stays flat with my account and verification pushed on top`() { val routes = listOf( AppRoute.Tabs.Menu, - AppRoute.Menu.MyAccount, + AppRoute.Menu.Settings, AppRoute.Verification( - origin = AppRoute.Menu.MyAccount, + origin = AppRoute.Menu.Settings, includePhone = false, email = "test@example.com", emailVerificationCode = "123456", diff --git a/settings.gradle.kts b/settings.gradle.kts index 64590ecf81..3c348af3bc 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -176,7 +176,6 @@ include( ":apps:flipcash:features:lab", ":apps:flipcash:features:appupdates", ":apps:flipcash:features:deposit", - ":apps:flipcash:features:advanced", ":apps:flipcash:features:currency-creator", ":apps:flipcash:features:messenger", ":apps:flipcash:features:invite", diff --git a/ui/navigation/src/main/kotlin/com/getcode/navigation/NavMetadata.kt b/ui/navigation/src/main/kotlin/com/getcode/navigation/NavMetadata.kt index 85845cb761..bf5164a1fd 100644 --- a/ui/navigation/src/main/kotlin/com/getcode/navigation/NavMetadata.kt +++ b/ui/navigation/src/main/kotlin/com/getcode/navigation/NavMetadata.kt @@ -29,7 +29,7 @@ enum class NavMetadataKeys(val key: String, ) { * Every destination is wrapped in a [Box] tagged with a stable screen-root id so the whole * screen is addressable as a single resource-id in UI tests (Maestro / UiAutomator, via * `testTagsAsResourceId`). The tag defaults to one derived from the route type name - * ([screenRootTag], e.g. `AppRoute.Menu.MyAccount` → `my_account_screen`); pass an explicit + * ([screenRootTag], e.g. `AppRoute.Menu.Settings` → `settings_screen`); pass an explicit * [testTag] only when a route needs an id that differs from its type name. * * Keeping the tag here — at the one place every route is registered — means screen-root From c992763fab72cac485821f79ecc54b63e67729f8 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 6 Oct 2026 14:44:01 -0400 Subject: [PATCH 2/4] feat(menu): open Settings from a gear on the You tab Drops the My Account row, the version footer and its tap-to-unlock from the You tab; the footer lives on Settings, which now also requests the debounced app update check the You-tab footer used to. --- .../com/flipcash/app/menu/MenuScreen.kt | 23 --- .../flipcash/app/menu/internal/MenuItems.kt | 20 -- .../app/menu/internal/MenuScreenContent.kt | 115 ++++------- .../app/menu/internal/MenuScreenViewModel.kt | 181 ------------------ .../flipcash/app/myaccount/SettingsScreen.kt | 9 + .../internal/settings/SettingsViewModel.kt | 14 ++ .../internal/SettingsViewModelTest.kt | 23 +++ 7 files changed, 81 insertions(+), 304 deletions(-) delete mode 100644 apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuItems.kt diff --git a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/MenuScreen.kt b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/MenuScreen.kt index 5793309ab6..71cc14bd27 100644 --- a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/MenuScreen.kt +++ b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/MenuScreen.kt @@ -6,12 +6,10 @@ import androidx.hilt.navigation.compose.hiltViewModel import com.flipcash.app.menu.internal.MenuScreenContent import com.flipcash.app.menu.internal.MenuScreenViewModel import com.getcode.navigation.core.LocalCodeNavigator -import com.getcode.ui.components.toast.LocalFloatingToastHost import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.launch @Composable fun MenuScreen() { @@ -19,7 +17,6 @@ fun MenuScreen() { MenuScreenContent(viewModel) val navigator = LocalCodeNavigator.current - val toasts = LocalFloatingToastHost.current LaunchedEffect(viewModel) { viewModel.eventFlow @@ -28,24 +25,4 @@ fun MenuScreen() { .onEach { navigator.push(it) } .launchIn(this) } - - // The menu is a tab home, so this rises out of the tab bar, over the version row the taps land - // on. It hugs the message and passes taps through, so each tap still reaches the row; each - // update swaps the text in place and restarts the timeout rather than replaying the entrance. - LaunchedEffect(viewModel, toasts) { - if (toasts == null) return@LaunchedEffect - viewModel.eventFlow - .filterIsInstance() - .onEach { event -> - launch { - toasts.show( - message = event.message, - hugContent = true, - passThrough = true, - inPlace = true, - ) - } - } - .launchIn(this) - } } diff --git a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuItems.kt b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuItems.kt deleted file mode 100644 index d8d75d1bd7..0000000000 --- a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuItems.kt +++ /dev/null @@ -1,20 +0,0 @@ -package com.flipcash.app.menu.internal - -import androidx.compose.runtime.Composable -import androidx.compose.ui.graphics.painter.Painter -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import com.flipcash.app.core.AppRoute -import com.flipcash.app.core.tokens.TokenPurpose -import com.flipcash.app.menu.FullMenuItem -import com.flipcash.features.menu.R - -internal data object MyAccount : FullMenuItem() { - override val icon: Painter - @Composable get() = painterResource(R.drawable.ic_people_id_card) - override val name: String - @Composable get() = stringResource(R.string.title_myAccount) - override val action: MenuScreenViewModel.Event = MenuScreenViewModel.Event.OpenScreen( - AppRoute.Menu.Settings - ) -} diff --git a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenContent.kt b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenContent.kt index 5a521e5875..8ffc7bc2ed 100644 --- a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenContent.kt +++ b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenContent.kt @@ -28,14 +28,17 @@ import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.statusBarsIgnoringVisibility import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider @@ -75,7 +78,7 @@ import com.flipcash.app.bills.components.cards.LocalTipCardColor import com.flipcash.app.core.bill.Scannable import com.flipcash.app.core.navigation.HideTabBar import com.flipcash.app.core.navigation.LocalTabBarPadding -import com.flipcash.app.menu.MenuList +import com.flipcash.app.core.AppRoute import com.flipcash.app.menu.internal.MenuScreenViewModel.Event import com.flipcash.app.menu.internal.MenuScreenViewModel.TipCardState import com.flipcash.app.core.ui.onboarding.NewUserTutorial @@ -84,7 +87,6 @@ import com.flipcash.app.core.util.abbreviatedLink import com.flipcash.app.menu.internal.components.UsernameProgress import com.flipcash.app.menu.internal.components.UsernameProgressCard import com.flipcash.app.theme.FlipcashThemeWrapper -import com.flipcash.app.updates.LocalAppUpdater import com.flipcash.services.models.UserProfile import com.flipcash.core.R as CoreR import com.flipcash.features.menu.R @@ -95,6 +97,8 @@ import com.getcode.theme.White08 import com.getcode.theme.White50 import com.getcode.theme.extraSmall import com.getcode.ui.core.noRippleClickable +import com.getcode.ui.core.verticalScrollStateGradient +import com.getcode.ui.utils.sheetResignmentBehavior import com.getcode.ui.theme.CodeScaffold import dev.chrisbanes.haze.HazeInput import dev.chrisbanes.haze.blur.HazeBlurDefaults @@ -111,7 +115,6 @@ import kotlinx.coroutines.flow.onEach @Composable internal fun MenuScreenContent(viewModel: MenuScreenViewModel) { val state by viewModel.stateFlow.collectAsStateWithLifecycle() - val appUpdater = LocalAppUpdater.current val listState = rememberLazyListState() // Full screen is a state of *this* screen, not a destination: the card grows into the middle of @@ -129,15 +132,8 @@ internal fun MenuScreenContent(viewModel: MenuScreenViewModel) { HideTabBar(hidden = cardExpanded) BackHandler(enabled = cardExpanded) { expansion.collapse() } - LaunchedEffect(Unit) { - viewModel.eventFlow - .filterIsInstance() - .onEach { appUpdater.checkForUpdate() } - .launchIn(this) - } - - // No app bar — the card is the first thing on the page (node 9276:4634). The version footer - // scrolls with the content (footer slot) rather than being pinned in a bottom bar. + // No app bar — the card is the first thing on the page (node 9276:4634). The only chrome is the + // settings gear, floated over the page's top trailing corner. CodeScaffold { padding -> BoxWithConstraints( modifier = Modifier @@ -258,16 +254,20 @@ internal fun MenuScreenContent(viewModel: MenuScreenViewModel) { }, ) - MenuList( + LazyColumn( modifier = Modifier .fillMaxSize() + .verticalScrollStateGradient(scrollState = listState, isLongGradient = true) + .sheetResignmentBehavior(listState) .then(cardDrag), state = listState, - items = state.items, - showChevrons = true, + contentPadding = PaddingValues( + top = restingTop, + bottom = bottomInset, + ), userScrollEnabled = !cardExpanded, - itemModifier = slideAway, - header = { + ) { + item { YouHeader( tipCardState = state.tipCardState, enabled = !cardExpanded, @@ -292,32 +292,25 @@ internal fun MenuScreenContent(viewModel: MenuScreenViewModel) { viewModel.dispatchEvent(Event.SetMinimumTip) }, ) - }, - footer = { - // Scrolls with the list, so it needs its own breathing room off the last - // row's divider. No navigationBarsPadding here — the reserved tab-bar inset - // below already clears the system bar (the bar measures itself with that - // padding in). - VersionFooter( - viewModel = viewModel, - state = state, - enabled = !cardExpanded, - modifier = slideAway.padding( - top = VersionFooterTopSpacing, - bottom = CodeTheme.dimens.grid.x3, - ), - ) - }, - contentPadding = PaddingValues( - top = restingTop, - bottom = bottomInset, - ), - onItemClick = { - // The faded-out rows are still laid out under the expanded card; don't let them - // take a tap meant for the card. - if (!cardExpanded) viewModel.dispatchEvent(it.action) } - ) + } + + // Floated over the page's top trailing corner so it clears the status bar. It rides the + // same fade as the rest of the page, and takes no taps while the card is up. + IconButton( + onClick = { viewModel.dispatchEvent(Event.OpenScreen(AppRoute.Menu.Settings)) }, + enabled = !cardExpanded, + modifier = Modifier + .align(Alignment.TopEnd) + .statusBarsPadding() + .then(slideAway), + ) { + Icon( + painter = painterResource(R.drawable.ic_settings_outline), + contentDescription = stringResource(CoreR.string.title_settings), + tint = CodeTheme.colors.textMain, + ) + } // Close sits at the foot of the display rather than under the card (node 9277:121410). // It fades on the same progress as everything else rather than on a transition of its @@ -369,14 +362,6 @@ private val FullScreenCardWidth: Dp private val CloseBottomSpacing: Dp @Composable get() = CodeTheme.dimens.grid.x2 -/** - * Clearance between the last settings row's divider and the version footer. iOS spends 32 above the - * footer plus 12 of the footer's own vertical padding on top of the row's 25 inset; the Android row - * already pays that same 25, so the difference lands here. - */ -private val VersionFooterTopSpacing: Dp - @Composable get() = CodeTheme.dimens.grid.x9 - /** How far the page's content slides down as it fades out under the expanding card. */ private val ContentSlideDistance: Dp @Composable get() = CodeTheme.dimens.grid.x12 @@ -970,36 +955,6 @@ private fun ShareTile( } } -/** The "Version … • Build …" footer; its repeated tap toggles beta access (see the ViewModel). */ -@Composable -private fun VersionFooter( - viewModel: MenuScreenViewModel, - state: MenuScreenViewModel.State, - modifier: Modifier = Modifier, - enabled: Boolean = true, -) { - Box(modifier = modifier.fillMaxWidth()) { - Text( - modifier = Modifier - .fillMaxWidth() - .align(Alignment.Center) - .noRippleClickable(enabled = enabled) { - viewModel.dispatchEvent(Event.OnVersionInfoClicked) - }, - text = stringResource( - R.string.subtitle_appVersionInfoFooter, - state.appVersionInfo.versionName, - state.appVersionInfo.versionCode, - state.releaseTrack, - ), - color = CodeTheme.colors.textSecondary, - style = CodeTheme.typography.textSmall.copy( - textAlign = TextAlign.Center - ), - ) - } -} - private val PreviewCodeData = listOf( 0xA5, 0x3C, 0xD7, 0x8B, 0x14, 0xE9, 0x62, 0xF0, 0x4D, 0xB6, 0x29, 0x7A, 0xC3, 0x58, 0x91, 0xDE, diff --git a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenViewModel.kt b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenViewModel.kt index bf9709da8c..7596c6d7dc 100644 --- a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenViewModel.kt +++ b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenViewModel.kt @@ -7,7 +7,6 @@ import com.flipcash.analytics.events.AddMoneyEvents import com.flipcash.app.analytics.FlipcashAnalytics import com.flipcash.app.bills.share.TipCodePreviewCache import com.flipcash.app.core.AppRoute -import com.flipcash.app.core.android.VersionInfo import com.flipcash.app.core.DisplayNameSource import com.flipcash.app.core.userprofile.UpdateProfileStep import com.flipcash.app.core.bill.Scannable @@ -16,20 +15,14 @@ import com.flipcash.app.core.share.TipCodeExportFormat import com.flipcash.app.core.share.TipCodeExporter import com.flipcash.app.core.ui.onboarding.TutorialItem import com.flipcash.app.core.util.Linkify -import com.flipcash.app.featureflags.BetaFeature import com.flipcash.app.core.toast.SystemToastController import com.flipcash.app.core.tipping.TipCardOwner -import com.flipcash.app.featureflags.FeatureFlagController -import com.flipcash.app.menu.MenuItem import com.flipcash.app.menu.internal.components.UsernameProgress import com.flipcash.app.funding.PurchaseMethodController import com.flipcash.app.shareable.ShareSheetController import com.flipcash.app.shareable.Shareable -import com.flipcash.app.updates.ReleaseStage -import com.flipcash.app.updates.ReleaseStageProvider import com.flipcash.app.tokens.core.TotalBalanceProvider import com.flipcash.app.userflags.UserFlagsCoordinator -import com.flipcash.features.menu.BuildConfig import com.flipcash.features.menu.R import com.flipcash.services.models.UserProfile import com.flipcash.services.user.AuthState @@ -43,18 +36,14 @@ import com.getcode.opencode.model.financial.Fiat import com.getcode.util.resources.ResourceHelper import com.getcode.view.BaseViewModel import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterIsInstance -import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.launch import javax.inject.Inject /** @@ -64,19 +53,12 @@ import javax.inject.Inject private fun Fiat.formattedGate(): String = formatted(rule = Fiat.FormattingRule.Truncated, suffix = currencyCode.name) -private val FullMenuList = buildList { - add(MyAccount) -} - @HiltViewModel internal class MenuScreenViewModel @Inject constructor( userManager: UserManager, userFlags: UserFlagsCoordinator, - versionInfo: VersionInfo, - featureFlags: FeatureFlagController, private val toastController: SystemToastController, dispatchers: DispatcherProvider, - releaseStageProvider: ReleaseStageProvider, purchaseMethodController: PurchaseMethodController, totalBalance: TotalBalanceProvider, analytics: FlipcashAnalytics, @@ -93,13 +75,6 @@ internal class MenuScreenViewModel @Inject constructor( defaultDispatcher = dispatchers.Default, ) { data class State( - val items: List> = FullMenuList, - val logoTapCount: Int = 0, - val isStaff: Boolean = false, - val flags: List = emptyList(), - val unlockedBetaFeaturesManually: Boolean = false, - val appVersionInfo: VersionInfo = VersionInfo(), - val releaseTrack: String = "", // The viewer's own tip card, shown at the top of the v2 "You" tab. val tipCardState: TipCardState = TipCardState.Unknown, // The nudge toward claiming a `@handle`, or null when there is nothing to nudge about — a @@ -143,13 +118,6 @@ internal class MenuScreenViewModel @Inject constructor( } sealed interface Event { - data object OnVersionInfoClicked: Event - data object CheckForUpdate: Event - data class OnBetaFeaturesUnlocked(val unlocked: Boolean): Event - data class OnFeatureFlagsUpdated(val flags: List): Event - data class OnAppVersionUpdated(val versionInfo: VersionInfo) : Event - data class OnReleaseTrackDetermined(val stage: String): Event - data class OnStaffUserDetermined(val staff: Boolean) : Event /** * Add money, tagged with what prompted it. The default covers the menu's own row; the * username gate passes its own source so a shortfall-driven deposit isn't reported as a @@ -160,11 +128,6 @@ internal class MenuScreenViewModel @Inject constructor( ) : Event data class OpenScreen(val screen: AppRoute) : Event - /** - * A developer-mode unlock message (the tap countdown, then unlocked). The screen floats it - * over the version row without taking taps, so the taps keep counting through it. - */ - data class ShowDevModeToast(val message: String) : Event data class OnTipCardStateChanged(val tipCardState: TipCardState) : Event data class OnUsernameProgressChanged( val progress: UsernameProgress?, @@ -189,70 +152,6 @@ internal class MenuScreenViewModel @Inject constructor( } init { - dispatchEvent(Event.OnAppVersionUpdated(versionInfo)) - dispatchEvent(Event.OnStaffUserDetermined(false)) - - userManager.state - .filter { it.authState is AuthState.Ready } - .flatMapLatest { userFlags.resolvedFlags } - .mapNotNull { it.isStaff.effectiveValue } - .onEach { - dispatchEvent(Event.OnStaffUserDetermined(it)) - }.launchIn(viewModelScope) - - featureFlags.observeOverride() - .onEach { dispatchEvent(Event.OnBetaFeaturesUnlocked(it)) } - .launchIn(viewModelScope) - - featureFlags.observe() - .onEach { dispatchEvent(Event.OnFeatureFlagsUpdated(it)) } - .launchIn(viewModelScope) - - viewModelScope.launch { - val resolvedStage = releaseStageProvider.resolvedStage - val label = when { - BuildConfig.DEBUG -> "development" - resolvedStage == null || resolvedStage == ReleaseStage.Production -> null - else -> resolvedStage.name.lowercase() - } - - val formattedLabel = if (label != null) { " • $label" } else "" - dispatchEvent(Event.OnReleaseTrackDetermined(formattedLabel)) - } - - eventFlow - .filterIsInstance() - .onEach { - if (stateFlow.value.unlockedBetaFeaturesManually) { - if (stateFlow.value.logoTapCount - TAP_THRESHOLD > COUNTDOWN_START) { - dispatchEvent(Event.ShowDevModeToast(resources.getString(R.string.toast_betaOverrideAlready))) - } - return@onEach - } - val remaining = TAP_THRESHOLD - stateFlow.value.logoTapCount + 1 - when { - remaining <= 0 -> { - featureFlags.enableBetaFeatures() - dispatchEvent(Event.ShowDevModeToast(resources.getString(R.string.toast_betaOverrideEnabled))) - } - remaining <= COUNTDOWN_START -> { - dispatchEvent( - Event.ShowDevModeToast( - resources.getQuantityString(R.plurals.toast_betaOverrideCountdown, remaining, remaining) - ) - ) - } - } - } - .launchIn(viewModelScope) - - @OptIn(FlowPreview::class) - eventFlow - .filterIsInstance() - .debounce(500) - .onEach { dispatchEvent(Event.CheckForUpdate) } - .launchIn(viewModelScope) - eventFlow .filterIsInstance() .mapNotNull { event -> @@ -495,75 +394,8 @@ internal class MenuScreenViewModel @Inject constructor( userId?.let { Linkify.tipcard(TipCardOwner.preferringUsername(profile.username, it)) } internal companion object { - private const val TAP_THRESHOLD = 6 - private const val COUNTDOWN_START = 3 - - private fun buildItemList( - isStaff: Boolean, - overrode: Boolean, - flags: List = emptyList(), - ): List> { - return if (isStaff || overrode) { - FullMenuList - .filter { item -> - val flagForItem = item.featureFlag - if (flagForItem != null) { - val match = flags.find { it.flag.key == flagForItem.key } - match?.enabled == true - } else { - true - } - } - } else { - FullMenuList.filterNot { it.isStaffOnly } - .filter { item -> - val flagForItem = item.featureFlag - if (flagForItem != null) { - val match = flags.find { it.flag.key == flagForItem.key } - match?.enabled == true - } else { - true - } - } - } - } - private val updateStateForEvent: (Event) -> ((State) -> State) = { event -> when (event) { - Event.OnVersionInfoClicked -> { state -> - state.copy(logoTapCount = state.logoTapCount + 1) - } - - is Event.OnBetaFeaturesUnlocked -> { state -> - state.copy( - unlockedBetaFeaturesManually = event.unlocked, - items = buildItemList( - isStaff = state.isStaff, - overrode = event.unlocked, - flags = state.flags - ) - ) - } - - is Event.OnAppVersionUpdated -> { state -> - state.copy(appVersionInfo = event.versionInfo) - } - - is Event.OnReleaseTrackDetermined -> { state -> - state.copy(releaseTrack = event.stage) - } - - is Event.OnStaffUserDetermined -> { state -> - state.copy( - isStaff = event.staff, - items = buildItemList( - isStaff = event.staff, - overrode = state.unlockedBetaFeaturesManually, - flags = state.flags, - ), - ) - } - is Event.OnTipCardStateChanged -> { state -> state.copy(tipCardState = event.tipCardState) } @@ -580,7 +412,6 @@ internal class MenuScreenViewModel @Inject constructor( } is Event.PresentDepositOptions, - Event.CheckForUpdate, Event.ClaimTipCard, Event.ClaimUsername, Event.SetProfilePicture, @@ -589,19 +420,7 @@ internal class MenuScreenViewModel @Inject constructor( Event.CopyTipLink, Event.DownloadTipCard, is Event.ExportTipCard, - is Event.ShowDevModeToast, is Event.OpenScreen -> { state -> state } - - is Event.OnFeatureFlagsUpdated -> { state -> - state.copy( - items = buildItemList( - isStaff = state.isStaff, - overrode = state.unlockedBetaFeaturesManually, - flags = event.flags - ), - flags = event.flags, - ) - } } } } diff --git a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/SettingsScreen.kt b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/SettingsScreen.kt index f6605c665e..231780cc08 100644 --- a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/SettingsScreen.kt +++ b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/SettingsScreen.kt @@ -13,6 +13,7 @@ import com.flipcash.app.bill.customization.LocalBillPlaygroundController import com.flipcash.app.core.AppRoute import com.flipcash.app.myaccount.internal.settings.SettingsScreen import com.flipcash.app.myaccount.internal.settings.SettingsViewModel +import com.flipcash.app.updates.LocalAppUpdater import com.flipcash.core.R import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.ui.components.AppBarDefaults @@ -28,6 +29,7 @@ fun SettingsScreen() { val navigator = LocalCodeNavigator.current val billPlayground = LocalBillPlaygroundController.current val toasts = LocalFloatingToastHost.current + val appUpdater = LocalAppUpdater.current val viewModel = hiltViewModel() @@ -60,6 +62,13 @@ fun SettingsScreen() { }.launchIn(this) } + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .onEach { appUpdater.checkForUpdate() } + .launchIn(this) + } + LaunchedEffect(viewModel) { viewModel.eventFlow .filterIsInstance() diff --git a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/settings/SettingsViewModel.kt b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/settings/SettingsViewModel.kt index 6b3acb3184..1a55f6ef64 100644 --- a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/settings/SettingsViewModel.kt +++ b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/settings/SettingsViewModel.kt @@ -24,8 +24,10 @@ import com.getcode.manager.BottomBarManager import com.getcode.util.resources.ResourceHelper import com.getcode.view.BaseViewModel import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.delay import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.launchIn @@ -114,6 +116,9 @@ internal class SettingsViewModel @Inject constructor( data object OnAccountDeleted : Event data object OnVersionInfoClicked : Event + + /** Tapping the footer also asks for an app-update check, once the taps settle. */ + data object CheckForUpdate : Event /** * A developer-mode unlock message (the tap countdown, then unlocked). The screen floats it * over the version row without taking taps, so the taps keep counting through it. @@ -200,6 +205,13 @@ internal class SettingsViewModel @Inject constructor( } }.launchIn(viewModelScope) + @OptIn(FlowPreview::class) + eventFlow + .filterIsInstance() + .debounce(UPDATE_CHECK_DEBOUNCE_MS) + .onEach { dispatchEvent(Event.CheckForUpdate) } + .launchIn(viewModelScope) + eventFlow .filterIsInstance() .onEach { @@ -269,6 +281,7 @@ internal class SettingsViewModel @Inject constructor( } internal companion object { + private const val UPDATE_CHECK_DEBOUNCE_MS = 500L private const val TAP_THRESHOLD = 6 private const val COUNTDOWN_START = 3 @@ -370,6 +383,7 @@ internal class SettingsViewModel @Inject constructor( Event.OnLoggedOutCompletely, Event.OnDeleteAccountClicked, Event.OnAccountDeleted, + Event.CheckForUpdate, is Event.ShowDevModeToast -> { state -> state } } } diff --git a/apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/SettingsViewModelTest.kt b/apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/SettingsViewModelTest.kt index 342e31af94..424cec228b 100644 --- a/apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/SettingsViewModelTest.kt +++ b/apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/SettingsViewModelTest.kt @@ -34,6 +34,10 @@ import io.mockk.verify import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceUntilIdle @@ -237,5 +241,24 @@ class SettingsViewModelTest { verify(exactly = 0) { featureFlags.enableBetaFeatures() } } + @Test + fun `a burst of footer taps asks for one update check once they settle`() = runTest(mainCoroutineRule.dispatcher) { + val vm = createViewModel() + val checks = mutableListOf() + val job = launch { + vm.eventFlow.filterIsInstance().toList(checks) + } + + repeat(3) { + vm.dispatchEvent(SettingsViewModel.Event.OnVersionInfoClicked) + advanceTimeBy(100) + } + assertTrue(checks.isEmpty()) + + advanceTimeBy(500) + assertEquals(1, checks.size) + job.cancel() + } + // endregion } From be1fe9b9cf8eae4e16f84d3ecb9a9b42c7b8bf83 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 6 Oct 2026 14:44:28 -0400 Subject: [PATCH 3/4] test(maestro): reach Settings through the gear The route rename moved my_account_screen to settings_screen and Advanced's rows into Settings, so the flows now tap the gear on the You tab and assert settings_screen. unlock_beta_features taps the footer on Settings. --- maestro/README.md | 8 +++--- maestro/account_navigation.yaml | 16 ++--------- maestro/blocking.yaml | 10 ++----- maestro/blockstore_switcher.yaml | 5 ++-- maestro/screenshots/groups/04_account.yaml | 6 ++-- maestro/screenshots/groups/05_features.yaml | 28 ++++++------------- maestro/subflows/logout.yaml | 4 +-- .../navigate_to_advanced_features.yaml | 11 -------- maestro/subflows/navigate_to_my_account.yaml | 11 -------- maestro/subflows/navigate_to_settings.yaml | 8 ++++++ maestro/subflows/unlock_beta_features.yaml | 4 +-- .../com/getcode/navigation/NavMetadata.kt | 2 +- 12 files changed, 37 insertions(+), 76 deletions(-) delete mode 100644 maestro/subflows/navigate_to_advanced_features.yaml delete mode 100644 maestro/subflows/navigate_to_my_account.yaml create mode 100644 maestro/subflows/navigate_to_settings.yaml diff --git a/maestro/README.md b/maestro/README.md index cccaa909b7..e5cdf2fa5c 100644 --- a/maestro/README.md +++ b/maestro/README.md @@ -72,7 +72,7 @@ Every routed screen is addressable by a stable `_screen` resource-id. Thes `AppScreenContent.kt` — not scattered across screen composables: - The tag defaults to one **derived from the route type name** (`screenRootTag` in - `NavMetadata.kt`): `AppRoute.Menu.MyAccount` → `my_account_screen`, + `NavMetadata.kt`): `AppRoute.Menu.Settings` → `settings_screen`, `AppRoute.Tabs.Scanner` → `scanner_screen`. - Pass an explicit `testTag` only when a route needs a different id than its type name, e.g. `annotatedEntry(testTag = "cash_screen") { ... }`. @@ -129,8 +129,8 @@ maestro/run.sh maestro/tipping_setup.yaml ## Coverage **Verified green** (run any of these with `maestro/run.sh`): -- `login_logout.yaml` — real seed-login UI + logout (Log Out lives on My Account) -- `account_navigation.yaml` — You tab → My Account, then Advanced +- `login_logout.yaml` — real seed-login UI + logout (Log Out lives on Settings) +- `account_navigation.yaml` — You tab → Settings (the gear) - `wallet_token_info.yaml` — wallet → token info + market-cap chart - `discovery_leaderboard.yaml` — wallet → Discover Currencies → leaderboard → token info - `withdraw.yaml` — menu → Withdraw Money → USDC → amount entry (fund-safe) @@ -146,7 +146,7 @@ maestro/run.sh maestro/tipping_setup.yaml - `chat_message_reply.yaml` — reply from the selection bar and from a trailing-ward swipe: the strip leaves the draft alone where an edit stashes it, and the sent bubble carries a citation that is tappable -- `blocking.yaml` — block a chat participant from their profile, verify in My Account → +- `blocking.yaml` — block a chat participant from their profile, verify in Settings → Blocked, then unblock (leaves the account clean) - `tip_deeplink.yaml` — open a tip-card deeplink (`TIPCARD_DEEPLINK`) → presents the tip flow (waits for balances to sync first, else the empty-cache state trips the add-money gate) diff --git a/maestro/account_navigation.yaml b/maestro/account_navigation.yaml index fc2173ecee..b054b57c40 100644 --- a/maestro/account_navigation.yaml +++ b/maestro/account_navigation.yaml @@ -7,23 +7,13 @@ tags: # Deterministic clean login → home. - runFlow: subflows/login_with_deeplink.yaml -# Menu → My Account -- runFlow: subflows/navigate_to_menu.yaml -# The You tab leads with the tip card, so both list rows sit below the fold. -- scrollUntilVisible: - element: "My Account" - direction: DOWN -- tapOn: "My Account" -- extendedWaitUntil: - visible: - id: my_account_screen - timeout: 10000 +# Menu → Settings (the gear) +- runFlow: subflows/navigate_to_settings.yaml -# Back to menu, then Advanced +# Back to the menu. - tapOn: id: action_back - extendedWaitUntil: visible: id: menu_screen timeout: 10000 -- runFlow: subflows/navigate_to_advanced_features.yaml diff --git a/maestro/blocking.yaml b/maestro/blocking.yaml index ee62e345dd..b2e6d5a2c3 100644 --- a/maestro/blocking.yaml +++ b/maestro/blocking.yaml @@ -28,16 +28,12 @@ tags: - tapOn: { text: "^Block$" } - extendedWaitUntil: { visible: { id: chats_screen }, timeout: 8000 } -# Verify they appear in My Account -> Blocked. Chats is a root tab, so the You tab is one tap +# Verify they appear in Settings -> Blocked. Chats is a root tab, so the You tab is one tap # away rather than behind a sheet dismiss. - tapOn: { id: nav_tipcard } - extendedWaitUntil: { visible: { id: menu_screen }, timeout: 8000 } -# The You tab leads with the tip card, so the list rows sit below the fold. -- scrollUntilVisible: - element: "My Account" - direction: DOWN -- tapOn: "My Account" -- extendedWaitUntil: { visible: { id: my_account_screen }, timeout: 8000 } +- tapOn: "Settings" +- extendedWaitUntil: { visible: { id: settings_screen }, timeout: 8000 } - tapOn: "Blocked" - extendedWaitUntil: { visible: { id: blocklist_screen }, timeout: 8000 } - assertVisible: "${CONTACT_NAME}" diff --git a/maestro/blockstore_switcher.yaml b/maestro/blockstore_switcher.yaml index 7866538eba..6a33671432 100644 --- a/maestro/blockstore_switcher.yaml +++ b/maestro/blockstore_switcher.yaml @@ -6,9 +6,8 @@ tags: --- - runFlow: subflows/login_with_deeplink.yaml # Switch Accounts is a StaffMenuItem, so it is hidden until the account is staff or the beta -# override is on. The test account is neither, hence the unlock. +# override is on. The test account is neither, hence the unlock, which leaves us on Settings. - runFlow: subflows/unlock_beta_features.yaml -- runFlow: subflows/navigate_to_advanced_features.yaml - scrollUntilVisible: element: "Switch Accounts" direction: DOWN @@ -26,5 +25,5 @@ tags: id: action_back - extendedWaitUntil: visible: - id: advanced_features_screen + id: settings_screen timeout: 10000 diff --git a/maestro/screenshots/groups/04_account.yaml b/maestro/screenshots/groups/04_account.yaml index 15003f57ff..1724cce504 100644 --- a/maestro/screenshots/groups/04_account.yaml +++ b/maestro/screenshots/groups/04_account.yaml @@ -5,12 +5,12 @@ tags: --- - runFlow: ../../subflows/login_with_deeplink.yaml -# Screenshot: My Account -- runFlow: ../../subflows/navigate_to_my_account.yaml +# Screenshot: Settings +- runFlow: ../../subflows/navigate_to_settings.yaml - runFlow: file: ../../helpers/screenshot.yaml env: - SCREENSHOT_NAME: my_account_screen + SCREENSHOT_NAME: settings_screen # Return to scanner - runFlow: ../../subflows/return_to_scanner.yaml diff --git a/maestro/screenshots/groups/05_features.yaml b/maestro/screenshots/groups/05_features.yaml index efd835a016..a562d0333a 100644 --- a/maestro/screenshots/groups/05_features.yaml +++ b/maestro/screenshots/groups/05_features.yaml @@ -1,31 +1,21 @@ appId: com.flipcash.app.android -name: "Screenshots: Advanced Features" +name: "Screenshots: Settings (bottom)" tags: - screenshot --- - runFlow: ../../subflows/login_with_deeplink.yaml -# Screenshot: Advanced Features -- runFlow: ../../subflows/navigate_to_advanced_features.yaml +# Screenshot: the lower half of Settings, past the account rows. +- runFlow: ../../subflows/navigate_to_settings.yaml +- scrollUntilVisible: + element: + text: "Version .*" + direction: DOWN + timeout: 20000 - runFlow: file: ../../helpers/screenshot.yaml env: - SCREENSHOT_NAME: advanced_features_screen - -# Navigate to Deposit from Advanced Features -- tapOn: Deposit Funds -- extendedWaitUntil: - visible: - text: Select Currency -- tapOn: Float -- extendedWaitUntil: - visible: - id: deposit_screen -- runFlow: - file: ../../helpers/screenshot.yaml - env: - SCREENSHOT_NAME: deposit_screen + SCREENSHOT_NAME: settings_bottom_screen # Return to scanner -- back - runFlow: ../../subflows/return_to_scanner.yaml diff --git a/maestro/subflows/logout.yaml b/maestro/subflows/logout.yaml index 132f25e33e..f105ac0e33 100644 --- a/maestro/subflows/logout.yaml +++ b/maestro/subflows/logout.yaml @@ -1,7 +1,7 @@ -# Reusable Logout flow. Log Out lives on the Advanced screen. +# Reusable Logout flow. Log Out lives on the Settings screen. appId: com.flipcash.app.android --- -- runFlow: navigate_to_advanced_features.yaml +- runFlow: navigate_to_settings.yaml - tapOn: Log Out - assertVisible: Log Out - assertVisible: Cancel diff --git a/maestro/subflows/navigate_to_advanced_features.yaml b/maestro/subflows/navigate_to_advanced_features.yaml deleted file mode 100644 index 83082ab068..0000000000 --- a/maestro/subflows/navigate_to_advanced_features.yaml +++ /dev/null @@ -1,11 +0,0 @@ -appId: com.flipcash.app.android ---- -- runFlow: navigate_to_menu.yaml -# The You tab leads with the tip card, so the list rows sit below the fold. -- scrollUntilVisible: - element: "Advanced" - direction: DOWN -- tapOn: Advanced -- extendedWaitUntil: - visible: - id: advanced_features_screen diff --git a/maestro/subflows/navigate_to_my_account.yaml b/maestro/subflows/navigate_to_my_account.yaml deleted file mode 100644 index 69c043e2e4..0000000000 --- a/maestro/subflows/navigate_to_my_account.yaml +++ /dev/null @@ -1,11 +0,0 @@ -appId: com.flipcash.app.android ---- -- runFlow: navigate_to_menu.yaml -# The You tab leads with the tip card, so the list rows sit below the fold. -- scrollUntilVisible: - element: "My Account" - direction: DOWN -- tapOn: My Account -- extendedWaitUntil: - visible: - id: my_account_screen diff --git a/maestro/subflows/navigate_to_settings.yaml b/maestro/subflows/navigate_to_settings.yaml new file mode 100644 index 0000000000..d5a97558cf --- /dev/null +++ b/maestro/subflows/navigate_to_settings.yaml @@ -0,0 +1,8 @@ +appId: com.flipcash.app.android +--- +- runFlow: navigate_to_menu.yaml +# The gear is the You tab's only route to Settings; its content description is "Settings". +- tapOn: "Settings" +- extendedWaitUntil: + visible: + id: settings_screen diff --git a/maestro/subflows/unlock_beta_features.yaml b/maestro/subflows/unlock_beta_features.yaml index a6b246af16..1751397a27 100644 --- a/maestro/subflows/unlock_beta_features.yaml +++ b/maestro/subflows/unlock_beta_features.yaml @@ -1,11 +1,11 @@ # Unlocks the staff/beta-only rows (Switch Accounts, and the rest) for a non-staff test account, -# the same way a human does it: seven taps on the version footer at the bottom of the menu. +# the same way a human does it: seven taps on the version footer at the bottom of Settings. # # `betaFlags` as a launch argument only flips individual FeatureFlag keys; it does not set the # beta override, which is what gates StaffMenuItem rows. This is the only route to that override. appId: com.flipcash.app.android --- -- runFlow: navigate_to_menu.yaml +- runFlow: navigate_to_settings.yaml - scrollUntilVisible: element: text: "Version .*" diff --git a/ui/navigation/src/main/kotlin/com/getcode/navigation/NavMetadata.kt b/ui/navigation/src/main/kotlin/com/getcode/navigation/NavMetadata.kt index bf5164a1fd..c9719fd889 100644 --- a/ui/navigation/src/main/kotlin/com/getcode/navigation/NavMetadata.kt +++ b/ui/navigation/src/main/kotlin/com/getcode/navigation/NavMetadata.kt @@ -46,7 +46,7 @@ inline fun EntryProviderScope.annotatedEntry( /** * Derives a screen-root test id from a route's simple type name: CamelCase becomes - * snake_case with a `_screen` suffix (e.g. `MyAccount` → `my_account_screen`, + * snake_case with a `_screen` suffix (e.g. `UserProfile` → `user_profile_screen`, * `Scanner` → `scanner_screen`). */ fun screenRootTag(simpleName: String?): String { From a7f7c3897a524812fdff4ae4c13a54def6ce8596 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 6 Oct 2026 14:52:10 -0400 Subject: [PATCH 4/4] fix(settings): keep the footer and gear clear of other content The Settings version footer scrolled under the gesture bar; the list now takes the navigation-bar inset as content padding. The You tab's gear moves from a floating overlay into the list, so it scrolls away with the page and the card keeps its resting offset. Also makes SettingsViewModel's OnBetaFeaturesUnlocked.manual explicit, asserts the already-a-developer toast in the tests, and drops unused menu module dependencies and drawables. --- apps/flipcash/features/menu/build.gradle.kts | 3 -- .../app/menu/internal/MenuScreenContent.kt | 51 +++++++++++-------- .../settings/SettingsScreenContent.kt | 9 +++- .../internal/settings/SettingsViewModel.kt | 2 +- .../internal/SettingsViewModelTest.kt | 50 ++++++++++-------- .../src/main/res/drawable/ic_maintenance.xml | 9 ---- .../main/res/drawable/ic_people_id_card.xml | 10 ---- 7 files changed, 67 insertions(+), 67 deletions(-) delete mode 100644 ui/resources/src/main/res/drawable/ic_maintenance.xml delete mode 100644 ui/resources/src/main/res/drawable/ic_people_id_card.xml diff --git a/apps/flipcash/features/menu/build.gradle.kts b/apps/flipcash/features/menu/build.gradle.kts index 68b80a11db..588a0898bd 100644 --- a/apps/flipcash/features/menu/build.gradle.kts +++ b/apps/flipcash/features/menu/build.gradle.kts @@ -12,12 +12,9 @@ dependencies { implementation(libs.bundles.haze) - implementation(project(":apps:flipcash:shared:appupdates")) implementation(project(":apps:flipcash:shared:analytics")) implementation(project(":apps:flipcash:shared:authentication")) implementation(project(":apps:flipcash:shared:bills")) - implementation(project(":apps:flipcash:shared:featureflags")) - implementation(project(":apps:flipcash:shared:menu")) implementation(project(":apps:flipcash:shared:funding")) implementation(project(":apps:flipcash:shared:shareable")) implementation(project(":apps:flipcash:shared:tipping")) diff --git a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenContent.kt b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenContent.kt index 8ffc7bc2ed..efb49b87ff 100644 --- a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenContent.kt +++ b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenContent.kt @@ -108,9 +108,6 @@ import dev.chrisbanes.haze.blur.hazeBlur import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.rememberHazeState import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.filterIsInstance -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach @Composable internal fun MenuScreenContent(viewModel: MenuScreenViewModel) { @@ -142,8 +139,10 @@ internal fun MenuScreenContent(viewModel: MenuScreenViewModel) { ) { // No app bar in v2, so the page owns its own status-bar clearance; the design puts the // card 74dp below it (node 9278:7301). - val restingTop = WindowInsets.statusBars.asPaddingValues() - .calculateTopPadding() + CardTopSpacing + // The settings gear is the first thing in the list, so it scrolls away with the page. + // The row it sits in takes the status-bar clearance plus the button's own height out of + // the card's resting offset, which leaves the card exactly where it was. + val restingTop = (CardTopSpacing - SettingsButtonSize).coerceAtLeast(0.dp) // The design's width, narrowed only if the display can't hold it inside the page's // margins — same rule iOS applies. val expandedCardWidth = minOf( @@ -267,6 +266,28 @@ internal fun MenuScreenContent(viewModel: MenuScreenViewModel) { ), userScrollEnabled = !cardExpanded, ) { + item(key = "settings_gear", contentType = "settings_gear") { + Box( + modifier = Modifier + .fillMaxWidth() + .statusBarsPadding() + .then(slideAway), + ) { + IconButton( + onClick = { + viewModel.dispatchEvent(Event.OpenScreen(AppRoute.Menu.Settings)) + }, + enabled = !cardExpanded, + modifier = Modifier.align(Alignment.TopEnd), + ) { + Icon( + painter = painterResource(R.drawable.ic_settings_outline), + contentDescription = stringResource(CoreR.string.title_settings), + tint = CodeTheme.colors.textMain, + ) + } + } + } item { YouHeader( tipCardState = state.tipCardState, @@ -295,23 +316,6 @@ internal fun MenuScreenContent(viewModel: MenuScreenViewModel) { } } - // Floated over the page's top trailing corner so it clears the status bar. It rides the - // same fade as the rest of the page, and takes no taps while the card is up. - IconButton( - onClick = { viewModel.dispatchEvent(Event.OpenScreen(AppRoute.Menu.Settings)) }, - enabled = !cardExpanded, - modifier = Modifier - .align(Alignment.TopEnd) - .statusBarsPadding() - .then(slideAway), - ) { - Icon( - painter = painterResource(R.drawable.ic_settings_outline), - contentDescription = stringResource(CoreR.string.title_settings), - tint = CodeTheme.colors.textMain, - ) - } - // Close sits at the foot of the display rather than under the card (node 9277:121410). // It fades on the same progress as everything else rather than on a transition of its // own, so a swipe held half-way leaves it half-faded instead of fully drawn. @@ -339,6 +343,9 @@ internal fun MenuScreenContent(viewModel: MenuScreenViewModel) { } } +/** The settings gear's touch target, which the card's resting offset is measured past. */ +private val SettingsButtonSize: Dp = 48.dp + /** Distance from the status bar to the top of the tip card (node 9278:7301: 74). */ private val CardTopSpacing: Dp @Composable get() = CodeTheme.dimens.grid.x15 diff --git a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/settings/SettingsScreenContent.kt b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/settings/SettingsScreenContent.kt index 60ff9d40ce..90d5e69968 100644 --- a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/settings/SettingsScreenContent.kt +++ b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/settings/SettingsScreenContent.kt @@ -1,6 +1,9 @@ package com.flipcash.app.myaccount.internal.settings import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -56,7 +59,11 @@ private fun SettingsScreenContent( Unit } - LazyColumn(modifier = Modifier.fillMaxSize()) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + // The version footer is the last row; without this it scrolls under the gesture bar. + contentPadding = WindowInsets.navigationBars.asPaddingValues(), + ) { state.sections.forEach { section -> item(key = section.title, contentType = "header") { SectionHeader( diff --git a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/settings/SettingsViewModel.kt b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/settings/SettingsViewModel.kt index 1a55f6ef64..e5b942daf2 100644 --- a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/settings/SettingsViewModel.kt +++ b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/settings/SettingsViewModel.kt @@ -90,7 +90,7 @@ internal class SettingsViewModel @Inject constructor( internal sealed interface Event { data class OnBetaFeaturesUnlocked( val unlocked: Boolean, - val manual: Boolean = unlocked, + val manual: Boolean, ) : Event data class OnFeatureFlagsUpdated(val flags: List) : Event data class OnBiometricsSettingChanged( diff --git a/apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/SettingsViewModelTest.kt b/apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/SettingsViewModelTest.kt index 424cec228b..da00ef73dd 100644 --- a/apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/SettingsViewModelTest.kt +++ b/apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/SettingsViewModelTest.kt @@ -31,12 +31,11 @@ import com.getcode.util.resources.FakeResourceHelper import io.mockk.every import io.mockk.mockk import io.mockk.verify +import app.cash.turbine.test import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.filterIsInstance -import kotlinx.coroutines.flow.toList -import kotlinx.coroutines.launch import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.TestScope @@ -99,18 +98,18 @@ class SettingsViewModelTest { @Test fun `staff rows appear once beta unlocks and leave again when it locks`() { - val unlocked = SettingsViewModel.State().after(SettingsViewModel.Event.OnBetaFeaturesUnlocked(true)) + val unlocked = SettingsViewModel.State().after(SettingsViewModel.Event.OnBetaFeaturesUnlocked(true, manual = true)) assertTrue(SwitchAccount in unlocked.items()) assertTrue(AccountInfo in unlocked.items()) - val relocked = unlocked.after(SettingsViewModel.Event.OnBetaFeaturesUnlocked(false)) + val relocked = unlocked.after(SettingsViewModel.Event.OnBetaFeaturesUnlocked(false, manual = false)) assertFalse(SwitchAccount in relocked.items()) assertFalse(AccountInfo in relocked.items()) } @Test fun `account info sits first in the account section`() { - val state = SettingsViewModel.State().after(SettingsViewModel.Event.OnBetaFeaturesUnlocked(true)) + val state = SettingsViewModel.State().after(SettingsViewModel.Event.OnBetaFeaturesUnlocked(true, manual = true)) val account = state.sections.last { it.title == R.string.title_settingsSectionAccount } assertEquals(listOf(AccountInfo, LogOut, DeleteAccount), account.items) } @@ -142,7 +141,7 @@ class SettingsViewModelTest { val state = SettingsViewModel.State().after( SettingsViewModel.Event.OnBiometricsSettingChanged(required = true, supported = false, available = false), SettingsViewModel.Event.OnUsernameClaimChanged(true), - SettingsViewModel.Event.OnBetaFeaturesUnlocked(true), + SettingsViewModel.Event.OnBetaFeaturesUnlocked(true, manual = true), ) assertFalse(RequireBiometrics in state.items()) assertTrue(ChangeUsername in state.items()) @@ -233,9 +232,19 @@ class SettingsViewModelTest { vm.dispatchEvent(SettingsViewModel.Event.OnBetaFeaturesUnlocked(unlocked = true, manual = true)) advanceUntilIdle() - repeat(10) { - vm.dispatchEvent(SettingsViewModel.Event.OnVersionInfoClicked) - advanceUntilIdle() + vm.eventFlow.filterIsInstance().test { + // The toast only starts once the taps run well past the unlock threshold. + repeat(10) { + vm.dispatchEvent(SettingsViewModel.Event.OnVersionInfoClicked) + advanceUntilIdle() + } + + assertEquals( + FakeResourceHelper().getString(R.string.toast_betaOverrideAlready), + awaitItem().message, + ) + expectNoEvents() + cancelAndIgnoreRemainingEvents() } verify(exactly = 0) { featureFlags.enableBetaFeatures() } @@ -244,20 +253,19 @@ class SettingsViewModelTest { @Test fun `a burst of footer taps asks for one update check once they settle`() = runTest(mainCoroutineRule.dispatcher) { val vm = createViewModel() - val checks = mutableListOf() - val job = launch { - vm.eventFlow.filterIsInstance().toList(checks) - } - repeat(3) { - vm.dispatchEvent(SettingsViewModel.Event.OnVersionInfoClicked) - advanceTimeBy(100) + vm.eventFlow.filterIsInstance().test { + repeat(3) { + vm.dispatchEvent(SettingsViewModel.Event.OnVersionInfoClicked) + advanceTimeBy(100) + } + expectNoEvents() + + advanceTimeBy(500) + awaitItem() + expectNoEvents() + cancelAndIgnoreRemainingEvents() } - assertTrue(checks.isEmpty()) - - advanceTimeBy(500) - assertEquals(1, checks.size) - job.cancel() } // endregion diff --git a/ui/resources/src/main/res/drawable/ic_maintenance.xml b/ui/resources/src/main/res/drawable/ic_maintenance.xml deleted file mode 100644 index 92e480342f..0000000000 --- a/ui/resources/src/main/res/drawable/ic_maintenance.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/ui/resources/src/main/res/drawable/ic_people_id_card.xml b/ui/resources/src/main/res/drawable/ic_people_id_card.xml deleted file mode 100644 index 267849fcd6..0000000000 --- a/ui/resources/src/main/res/drawable/ic_people_id_card.xml +++ /dev/null @@ -1,10 +0,0 @@ - - -