diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/inject/AppModule.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/inject/AppModule.kt index cdc4f5b64..cbc65b893 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/inject/AppModule.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/inject/AppModule.kt @@ -7,6 +7,8 @@ import androidx.core.app.NotificationManagerCompat import com.flipcash.app.android.BuildConfig import com.flipcash.app.core.android.VersionInfo import com.flipcash.app.core.annotations.AccountType +import com.flipcash.app.core.toast.SystemToastController +import com.flipcash.app.internal.toast.AndroidSystemToastController import com.getcode.util.resources.AndroidContentReader import com.getcode.util.resources.AndroidResources import com.getcode.util.resources.AndroidSettingsHelper @@ -66,4 +68,10 @@ object AppModule { fun providesBiometricsManager( @ApplicationContext context: Context ): BiometricManager = BiometricManager.from(context) + + @Provides + @Singleton + fun providesSystemToastController( + @ApplicationContext context: Context + ): SystemToastController = AndroidSystemToastController(context) } \ No newline at end of file diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/toast/AndroidSystemToastController.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/toast/AndroidSystemToastController.kt new file mode 100644 index 000000000..9d3c42460 --- /dev/null +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/toast/AndroidSystemToastController.kt @@ -0,0 +1,31 @@ +package com.flipcash.app.internal.toast + +import android.content.Context +import android.widget.Toast +import com.flipcash.app.core.toast.SystemToastController + +internal class AndroidSystemToastController( + private val context: Context, +) : SystemToastController { + private var activeToast: Toast? = null + + private fun show(toast: Toast, replacePrevious: Boolean) { + if (replacePrevious) { + activeToast?.cancel() + } + activeToast = toast + toast.show() + } + + override fun showToast(message: String, replacePrevious: Boolean) { + show(Toast.makeText(context, message, Toast.LENGTH_LONG), replacePrevious) + } + + override fun showToast(messageRes: Int, vararg args: Any, replacePrevious: Boolean) { + show(Toast.makeText(context, context.getString(messageRes, *args), Toast.LENGTH_SHORT), replacePrevious) + } + + override fun showQuantityToast(pluralRes: Int, quantity: Int, vararg args: Any, replacePrevious: Boolean) { + show(Toast.makeText(context, context.resources.getQuantityString(pluralRes, quantity, *args), Toast.LENGTH_SHORT), replacePrevious) + } +} 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 31ae98d68..7c903c145 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 @@ -122,7 +122,7 @@ fun appEntryProvider( // Menu annotatedEntry { AppSettingsScreen() } - annotatedEntry { LabsScreen() } + annotatedEntry { key -> LabsScreen(onboarding = key.onboarding) } annotatedEntry { NavBarSettingsScreen() } annotatedEntry { UserProfileScreen() } annotatedEntry { MyAccountScreen() } 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 98dcd43e0..19a608e00 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 @@ -233,7 +233,7 @@ sealed interface AppRoute : NavKey, Parcelable { @Serializable data object UserProfile : Menu @Serializable - data object Lab : Menu + data class Lab(val onboarding: Boolean = false) : Menu @Serializable data object NavBarSettings : Menu, com.getcode.navigation.Sheet, com.getcode.navigation.WrapContentSheet } diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/toast/SystemToastController.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/toast/SystemToastController.kt new file mode 100644 index 000000000..b41d21c2d --- /dev/null +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/toast/SystemToastController.kt @@ -0,0 +1,10 @@ +package com.flipcash.app.core.toast + +import androidx.annotation.PluralsRes +import androidx.annotation.StringRes + +interface SystemToastController { + fun showToast(message: String, replacePrevious: Boolean = false) + fun showToast(@StringRes messageRes: Int, vararg args: Any, replacePrevious: Boolean = false) + fun showQuantityToast(@PluralsRes pluralRes: Int, quantity: Int, vararg args: Any, replacePrevious: Boolean = false) +} diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 58d3e9aee..431362d68 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -131,6 +131,16 @@ Nothing Cooking in the Lab Right Now Check back in the next app update. + Beta Overrides + All flags are visible. Toggle off to reset flags to defaults. + Flags reset to defaults. Changes take effect on next visit. + You are now a developer! + No need, you are already a developer + + You are now %1$d step away from being a developer + You are now %1$d steps away from being a developer + + Permanently Delete Account? This will permanently delete your Flipcash account Delete Account 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 index 15ba3dae5..7263a75d2 100644 --- 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 @@ -36,5 +36,5 @@ internal data object BetaFlags : FullMenuItem() - LabsScreenContent(viewModel) + LabsScreenContent(viewModel, onboarding) } } diff --git a/apps/flipcash/features/lab/src/main/kotlin/com/flipcash/app/lab/internal/LabsScreenContent.kt b/apps/flipcash/features/lab/src/main/kotlin/com/flipcash/app/lab/internal/LabsScreenContent.kt index 721f84282..81fd8be37 100644 --- a/apps/flipcash/features/lab/src/main/kotlin/com/flipcash/app/lab/internal/LabsScreenContent.kt +++ b/apps/flipcash/features/lab/src/main/kotlin/com/flipcash/app/lab/internal/LabsScreenContent.kt @@ -12,11 +12,15 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ContactMail import androidx.compose.material.icons.filled.Token +import androidx.compose.material.Surface import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.rememberVectorPainter @@ -41,16 +45,24 @@ import com.getcode.ui.theme.CodeSegmentedControl import com.getcode.ui.utils.sheetResignmentBehavior @Composable -internal fun LabsScreenContent(viewModel: LabsScreenViewModel) { +internal fun LabsScreenContent(viewModel: LabsScreenViewModel, onboarding: Boolean = false) { val betaFlagsController = LocalFeatureFlags.current val allFlags by betaFlagsController.observe().collectAsStateWithLifecycle() val betaOverride by viewModel.betaOverride.collectAsStateWithLifecycle() val navigator = LocalCodeNavigator.current val isStaff by viewModel.isStaff.collectAsStateWithLifecycle() - val betaFlags = remember(allFlags, betaOverride) { - if (betaOverride) allFlags - else allFlags.filter { it.flag.minTrack == FeatureTrack.Production } + // Keep showing all flags even after toggling off override, until leaving the screen + var showAllFlags by remember { mutableStateOf(betaOverride) } + LaunchedEffect(betaOverride) { + if (betaOverride) showAllFlags = true + } + + val betaFlags = remember(allFlags, showAllFlags, onboarding) { + if (showAllFlags) allFlags + else allFlags.filter { + it.flag.minTrack == FeatureTrack.Production || (onboarding && it.flag.onboarding) + } } val state = rememberLazyListState() @@ -64,7 +76,28 @@ internal fun LabsScreenContent(viewModel: LabsScreenViewModel) { .sheetResignmentBehavior(state), contentPadding = PaddingValues(bottom = CodeTheme.dimens.grid.x3), ) { - item(contentType = "section_header") { + if (showAllFlags) { + item(contentType = "override_toggle") { + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = CodeTheme.dimens.inset) + .padding(top = CodeTheme.dimens.grid.x2), + shape = CodeTheme.shapes.medium, + color = CodeTheme.colors.surfaceVariant, + ) { + SettingsSwitchRow( + title = stringResource(R.string.title_betaOverride), + subtitle = stringResource(R.string.subtitle_betaOverride), + checked = betaOverride, + ) { + viewModel.disableBetaFeatures() + } + } + } + } + + item(contentType = "section_header") { SectionHeader( modifier = Modifier.padding(horizontal = CodeTheme.dimens.inset), title = stringResource(R.string.title_settingsSectionFeatures) @@ -127,7 +160,7 @@ internal fun LabsScreenContent(viewModel: LabsScreenViewModel) { } } - if (betaOverride) { + if (showAllFlags) { item(contentType = "section_header") { SectionHeader( modifier = Modifier.padding(horizontal = CodeTheme.dimens.inset), @@ -144,7 +177,7 @@ internal fun LabsScreenContent(viewModel: LabsScreenViewModel) { } } - if (betaOverride && isStaff) { + if (showAllFlags && isStaff) { item(contentType = "section_header") { SectionHeader( modifier = Modifier.padding(horizontal = CodeTheme.dimens.inset), diff --git a/apps/flipcash/features/lab/src/main/kotlin/com/flipcash/app/lab/internal/LabsScreenViewModel.kt b/apps/flipcash/features/lab/src/main/kotlin/com/flipcash/app/lab/internal/LabsScreenViewModel.kt index 89a85eaa9..13c6f5e54 100644 --- a/apps/flipcash/features/lab/src/main/kotlin/com/flipcash/app/lab/internal/LabsScreenViewModel.kt +++ b/apps/flipcash/features/lab/src/main/kotlin/com/flipcash/app/lab/internal/LabsScreenViewModel.kt @@ -2,8 +2,10 @@ package com.flipcash.app.lab.internal import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.flipcash.app.core.toast.SystemToastController import com.flipcash.app.featureflags.FeatureFlagController import com.flipcash.app.userflags.UserFlagsCoordinator +import com.flipcash.core.R import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.map @@ -13,11 +15,17 @@ import javax.inject.Inject @HiltViewModel class LabsScreenViewModel @Inject constructor( userFlags: UserFlagsCoordinator, - featureFlagController: FeatureFlagController, + private val featureFlagController: FeatureFlagController, + private val toastController: SystemToastController, ) : ViewModel() { val isStaff = userFlags.resolvedFlags.map { it.isStaff.effectiveValue } .stateIn(viewModelScope, started = SharingStarted.WhileSubscribed(5_000), initialValue = false) val betaOverride = featureFlagController.observeOverride() + + fun disableBetaFeatures() { + featureFlagController.disableBetaFeatures() + toastController.showToast(R.string.toast_betaOverrideDisabled) + } } diff --git a/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/OnboardingFlowScreen.kt b/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/OnboardingFlowScreen.kt index bee7d74b8..6ffb6df5e 100644 --- a/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/OnboardingFlowScreen.kt +++ b/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/OnboardingFlowScreen.kt @@ -354,7 +354,7 @@ private fun LoginStepContent(seed: String?) { login = { flowNavigator.navigateTo(OnboardingStep.SeedInput) }, isLabsOpen = state.betaOptionsVisible, onLogoTapped = { vm.dispatchEvent(LoginViewModel.Event.OnLogoTapped) }, - openBetaFlags = { outerNavigator.openAsSheet(AppRoute.Menu.Lab) }, + openBetaFlags = { outerNavigator.openAsSheet(AppRoute.Menu.Lab(onboarding = true)) }, ) } } 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 44c945caa..001b4e533 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,6 +7,7 @@ import com.flipcash.app.core.android.VersionInfo import com.flipcash.app.core.extensions.onResult import com.flipcash.app.featureflags.BetaFeature import com.flipcash.app.featureflags.FeatureFlag +import com.flipcash.app.core.toast.SystemToastController import com.flipcash.app.featureflags.FeatureFlagController import com.flipcash.app.menu.MenuItem import com.flipcash.app.payments.PurchaseMethodController @@ -19,7 +20,6 @@ import com.flipcash.services.user.AuthState import com.flipcash.services.user.UserManager import com.getcode.manager.BottomBarManager import com.getcode.opencode.managers.MnemonicManager -import com.getcode.util.resources.ResourceHelper import com.flipcash.libs.coroutines.DispatcherProvider import com.getcode.manager.BottomBarAction import com.getcode.view.BaseViewModel @@ -53,6 +53,7 @@ internal class MenuScreenViewModel @Inject constructor( versionInfo: VersionInfo, mnemonicManager: MnemonicManager, featureFlags: FeatureFlagController, + private val toastController: SystemToastController, dispatchers: DispatcherProvider, releaseStageProvider: ReleaseStageProvider, purchaseMethodController: PurchaseMethodController, @@ -120,10 +121,24 @@ internal class MenuScreenViewModel @Inject constructor( eventFlow .filterIsInstance() - .map { stateFlow.value.logoTapCount } - .filter { it > TAP_THRESHOLD } - .filterNot { stateFlow.value.unlockedBetaFeaturesManually } - .onEach { featureFlags.enableBetaFeatures() } + .onEach { + if (stateFlow.value.unlockedBetaFeaturesManually) { + if (stateFlow.value.logoTapCount - TAP_THRESHOLD > COUNTDOWN_START) { + toastController.showToast(R.string.toast_betaOverrideAlready, replacePrevious = true) + } + return@onEach + } + val remaining = TAP_THRESHOLD - stateFlow.value.logoTapCount + 1 + when { + remaining <= 0 -> { + featureFlags.enableBetaFeatures() + toastController.showToast(R.string.toast_betaOverrideEnabled, replacePrevious = true) + } + remaining <= COUNTDOWN_START -> { + toastController.showQuantityToast(R.plurals.toast_betaOverrideCountdown, remaining, remaining, replacePrevious = true) + } + } + } .launchIn(viewModelScope) @OptIn(FlowPreview::class) @@ -165,6 +180,7 @@ internal class MenuScreenViewModel @Inject constructor( internal companion object { private const val TAP_THRESHOLD = 6 + private const val COUNTDOWN_START = 3 private fun buildItemList( isStaff: Boolean, diff --git a/apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/FeatureFlag.kt b/apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/FeatureFlag.kt index 66889c33b..b04d730f0 100644 --- a/apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/FeatureFlag.kt +++ b/apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/FeatureFlag.kt @@ -22,6 +22,7 @@ sealed interface FeatureFlag { val visible: Boolean val persistLogOut: Boolean val minTrack: FeatureTrack get() = FeatureTrack.Internal + val onboarding: Boolean get() = false val options: List get() = emptyList() val defaultOption: String get() = if (default is Enum<*>) (default as Enum<*>).name else "" @@ -40,6 +41,7 @@ sealed interface FeatureFlag { override val launched: Boolean = false override val visible: Boolean = true override val persistLogOut: Boolean = true + override val onboarding: Boolean = true } @FeatureFlagMarker @@ -205,6 +207,7 @@ sealed interface FeatureFlag { override val launched: Boolean = false override val visible: Boolean = true override val persistLogOut: Boolean = true + override val onboarding: Boolean = true } @FeatureFlagMarker diff --git a/apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/FeatureFlagController.kt b/apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/FeatureFlagController.kt index c2241d1e9..4c2b7e2d5 100644 --- a/apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/FeatureFlagController.kt +++ b/apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/FeatureFlagController.kt @@ -6,6 +6,7 @@ import kotlinx.coroutines.flow.StateFlow interface FeatureFlagController { fun enableBetaFeatures() + fun disableBetaFeatures() fun observeOverride(): StateFlow fun set(flag: FeatureFlag<*>, value: Boolean) suspend fun get(flag: FeatureFlag<*>): Boolean @@ -19,6 +20,7 @@ interface FeatureFlagController { object NoOpFeatureFlagController : FeatureFlagController { override fun enableBetaFeatures() = Unit + override fun disableBetaFeatures() = Unit override fun observeOverride(): StateFlow = MutableStateFlow(false) override fun set(flag: FeatureFlag<*>, value: Boolean) = Unit diff --git a/apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/internal/InternalFeatureFlagController.kt b/apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/internal/InternalFeatureFlagController.kt index 3de67c14d..8fe222594 100644 --- a/apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/internal/InternalFeatureFlagController.kt +++ b/apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/internal/InternalFeatureFlagController.kt @@ -73,6 +73,20 @@ internal class InternalFeatureFlagController @Inject constructor( } } + override fun disableBetaFeatures() { + dataScope.launch(Dispatchers.IO) { + betaFlags.edit { prefs -> + prefs[betaOverrideKey] = false + FeatureFlag.availableEntries.forEach { flag -> + prefs.remove(flag.booleanPreferenceKey) + if (flag.isOptionFlag) { + prefs.remove(flag.optionPreferenceKey) + } + } + } + } + } + override fun observeOverride(): StateFlow = betaFlags.data.map { prefs -> prefs[betaOverrideKey] ?: false } .stateIn(dataScope, SharingStarted.Eagerly, false)