diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/App.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/App.kt index c7c8a5494..da6a9ae8d 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/App.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/App.kt @@ -15,9 +15,11 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.semantics @@ -32,45 +34,50 @@ import androidx.navigation3.scene.SinglePaneSceneStrategy import com.flipcash.app.analytics.rememberAnalytics import com.flipcash.app.android.BuildConfig import com.flipcash.app.bill.customization.BillPlaygroundScaffold -import com.flipcash.app.core.LocalUserManager import com.flipcash.app.core.AppRoute -import com.flipcash.app.core.verification.email.LocalEmailCodeChannel +import com.flipcash.app.core.LocalUserManager +import com.flipcash.app.core.extensions.navigateTo import com.flipcash.app.core.navigation.DeeplinkAction +import com.flipcash.app.core.verification.email.LocalEmailCodeChannel +import com.flipcash.app.featureflags.FeatureFlag +import com.flipcash.app.featureflags.LocalFeatureFlags +import com.flipcash.app.featureflags.model.BackgroundResetTimeout import com.flipcash.app.internal.ui.navigation.appEntryProvider import com.flipcash.app.internal.ui.navigation.decorators.rememberNavBlockingOverlayEntryDecorator import com.flipcash.app.internal.ui.navigation.decorators.rememberNavMessagingEntryDecorator import com.flipcash.app.onramp.CoinbaseOnRampHandler import com.flipcash.app.onramp.ExternalWalletOnRampHandler import com.flipcash.app.onramp.LocalExternalWalletOnRampController -import com.flipcash.app.onramp.LocalCoinbaseOnRampController import com.flipcash.app.router.LocalRouter import com.flipcash.app.session.LocalSessionController import com.flipcash.app.theme.FlipcashTheme import com.flipcash.features.shareapp.R import com.flipcash.services.user.AuthState +import com.getcode.animation.LocalSharedTransitionScope import com.getcode.libs.biometrics.BiometricsError import com.getcode.libs.qr.rememberQrBitmapPainter import com.getcode.navigation.AppNavHost +import com.getcode.navigation.Sheet +import com.getcode.navigation.core.CodeNavigator import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.navigation.core.rememberCodeNavigator import com.getcode.navigation.extensions.getActivityScopedViewModel import com.getcode.navigation.results.rememberNavResultStateRegistry import com.getcode.navigation.scenes.ModalBottomSheetSceneStrategy +import com.getcode.navigation.scrim.LocalScrimController +import com.getcode.navigation.scrim.ScrimController +import com.getcode.navigation.scrim.ScrimOverlay import com.getcode.theme.CodeTheme import com.getcode.ui.biometrics.LocalBiometricsState import com.getcode.ui.biometrics.rememberBiometricsState import com.getcode.ui.components.OnLifecycleEvent import com.getcode.ui.components.bars.rememberBarManager import com.getcode.ui.core.RestrictionType -import com.flipcash.app.core.extensions.navigateTo -import com.getcode.animation.LocalSharedTransitionScope -import com.getcode.navigation.scrim.LocalScrimController -import com.getcode.navigation.scrim.ScrimController -import com.getcode.navigation.scrim.ScrimOverlay import dev.bmcreations.tipkit.TipScaffold import dev.bmcreations.tipkit.engines.TipsEngine import dev.theolm.rinku.DeepLink import dev.theolm.rinku.compose.ext.DeepLinkListener +import kotlinx.coroutines.flow.first @Composable internal fun App( @@ -96,6 +103,7 @@ internal fun App( } var deepLink by remember { mutableStateOf(null) } + var deeplinkHandled by remember { mutableStateOf(false) } val userManager = LocalUserManager.current!! DeepLinkListener { analytics.deeplinkOpened(it.data) @@ -239,7 +247,9 @@ internal fun App( return@LaunchedEffect } - when (val action = router.dispatch(link)) { + val action = router.dispatch(link) + deeplinkHandled = action != DeeplinkAction.None + when (action) { is DeeplinkAction.Navigate -> { // If a verification code targets a screen already open, // deliver via side-channel and skip navigation. @@ -323,6 +333,13 @@ internal fun App( else -> Unit } } + + BackgroundResetEffect( + navigator = codeNavigator, + deepLink = { deepLink }, + deeplinkHandled = { deeplinkHandled }, + onReset = { deeplinkHandled = false }, + ) } } } @@ -331,3 +348,51 @@ internal fun App( } } +@Composable +private fun BackgroundResetEffect( + navigator: CodeNavigator, + deepLink: () -> DeepLink?, + deeplinkHandled: () -> Boolean, + onReset: () -> Unit, +) { + val featureFlags = LocalFeatureFlags.current + val option by featureFlags.getOption(FeatureFlag.BackgroundReset) + .collectAsStateWithLifecycle() + + var pendingReset by remember { mutableStateOf(false) } + var backgroundedAt by remember { mutableLongStateOf(0L) } + + OnLifecycleEvent { _, event -> + when (event) { + Lifecycle.Event.ON_STOP -> { + backgroundedAt = System.currentTimeMillis() + } + Lifecycle.Event.ON_RESUME -> { + val timeout = runCatching { BackgroundResetTimeout.valueOf(option) } + .getOrNull() + ?.duration + + if (timeout != null && backgroundedAt > 0L) { + val elapsed = System.currentTimeMillis() - backgroundedAt + if (elapsed >= timeout.inWholeMilliseconds) { + pendingReset = true + } + } + backgroundedAt = 0L + } + else -> Unit + } + } + + LaunchedEffect(pendingReset) { + if (!pendingReset) return@LaunchedEffect + // Wait for any pending deeplink to be consumed before deciding + snapshotFlow { deepLink() }.first { it == null } + if (!deeplinkHandled()) { + navigator.popUntil { it !is Sheet } + } + onReset() + pendingReset = false + } +} + diff --git a/apps/flipcash/features/bill-customization/src/main/kotlin/com/flipcash/app/bill/customization/components/BillPlayground.kt b/apps/flipcash/features/bill-customization/src/main/kotlin/com/flipcash/app/bill/customization/components/BillPlayground.kt index 0ca18b841..43c0602ea 100644 --- a/apps/flipcash/features/bill-customization/src/main/kotlin/com/flipcash/app/bill/customization/components/BillPlayground.kt +++ b/apps/flipcash/features/bill-customization/src/main/kotlin/com/flipcash/app/bill/customization/components/BillPlayground.kt @@ -217,7 +217,7 @@ internal fun Modifier.presenceBorder( ) private object PreviewFeatureFlagController : FeatureFlagController by NoOpFeatureFlagController { - override fun observe(flag: FeatureFlag): StateFlow = MutableStateFlow(true) + override fun observe(flag: FeatureFlag<*>): StateFlow = MutableStateFlow(true) } @Composable 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 b123029c3..cef9788b8 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 @@ -25,6 +25,7 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flipcash.app.core.AppRoute import com.flipcash.app.core.extensions.navigateTo +import com.flipcash.app.featureflags.FlagOption import com.flipcash.app.featureflags.LocalFeatureFlags import com.flipcash.app.featureflags.message import com.flipcash.app.featureflags.title @@ -35,6 +36,7 @@ import com.getcode.ui.components.ListItem import com.getcode.ui.components.SettingsSwitchRow import com.getcode.ui.components.text.SectionHeader import com.getcode.ui.core.verticalScrollStateGradient +import com.getcode.ui.theme.CodeSegmentedControl import com.getcode.ui.utils.sheetResignmentBehavior @Composable @@ -59,12 +61,24 @@ internal fun LabsScreenContent(viewModel: LabsScreenViewModel) { SectionHeader(stringResource(R.string.title_settingsSectionFeatures)) } items(betaFlags, key = { it.flag.key }) { feature -> - SettingsSwitchRow( - title = feature.flag.title, - subtitle = feature.flag.message, - checked = feature.enabled - ) { - betaFlagsController.set(feature.flag, !feature.enabled) + if (feature.flag.isOptionFlag) { + SettingsOptionRow( + title = feature.flag.title, + subtitle = feature.flag.message, + options = feature.flag.options, + selectedOption = feature.selectedOption ?: feature.flag.defaultOption, + onOptionSelected = { optionKey -> + betaFlagsController.setOption(feature.flag, optionKey) + }, + ) + } else { + SettingsSwitchRow( + title = feature.flag.title, + subtitle = feature.flag.message, + checked = feature.enabled + ) { + betaFlagsController.set(feature.flag, !feature.enabled) + } } HorizontalDivider( @@ -133,4 +147,46 @@ internal fun LabsScreenContent(viewModel: LabsScreenViewModel) { } } } +} + +@Composable +private fun SettingsOptionRow( + title: String, + subtitle: String?, + options: List, + selectedOption: String, + onOptionSelected: (String) -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = CodeTheme.dimens.grid.x3) + .padding(vertical = CodeTheme.dimens.grid.x3), + ) { + Text( + text = title, + color = CodeTheme.colors.textMain, + style = CodeTheme.typography.textMedium, + ) + if (!subtitle.isNullOrEmpty()) { + Text( + text = subtitle, + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + ) + } + CodeSegmentedControl( + options = options, + selected = options.find { it.key == selectedOption }, + modifier = Modifier + .fillMaxWidth() + .padding(top = CodeTheme.dimens.grid.x2), + mapper = { option -> + Text(text = option.label) + }, + onSelectionChanged = { option -> + onOptionSelected(option.key) + }, + ) + } } \ 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 9ed610420..a823c0044 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 @@ -63,7 +63,7 @@ internal data object SwitchAccount : StaffMenuItem() override val name: String @Composable get() = stringResource(R.string.title_switchAccounts) override val action: MenuScreenViewModel.Event = MenuScreenViewModel.Event.OnSwitchAccountsClicked - override val featureFlag: FeatureFlag = FeatureFlag.CredentialManager + override val featureFlag: FeatureFlag<*> = FeatureFlag.CredentialManager } internal data object Labs : StaffMenuItem() { diff --git a/apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/BetaFeature.kt b/apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/BetaFeature.kt index 7d0bdf92b..5fab6ded1 100644 --- a/apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/BetaFeature.kt +++ b/apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/BetaFeature.kt @@ -1,6 +1,7 @@ package com.flipcash.app.featureflags data class BetaFeature( - val flag: FeatureFlag, + val flag: FeatureFlag<*>, val enabled: Boolean, + val selectedOption: String? = null, ) \ No newline at end of file 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 20eafc039..957a3c5d9 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 @@ -1,16 +1,29 @@ package com.flipcash.app.featureflags import com.flipcash.app.ksp.annotations.FeatureFlagMarker +import kotlin.time.Duration +import kotlin.time.Duration.Companion.minutes -sealed interface FeatureFlag { +data class FlagOption(val key: String, val label: String, val isDisabled: Boolean = false) +sealed interface FeatureFlag { val key: String - val default: Boolean + val default: T val launched: Boolean val visible: Boolean val persistLogOut: Boolean + val options: List get() = emptyList() + val defaultOption: String + get() = if (default is Enum<*>) (default as Enum<*>).name else "" + val defaultEnabled: Boolean + get() = if (isOptionFlag) { + options.find { it.key == defaultOption }?.isDisabled != true + } else { + default as Boolean + } + val isOptionFlag: Boolean get() = options.isNotEmpty() @FeatureFlagMarker - data object CredentialManager: FeatureFlag { + data object CredentialManager: FeatureFlag { override val key: String = "credential_manager_enabled" override val default: Boolean = false override val launched: Boolean = false @@ -19,7 +32,7 @@ sealed interface FeatureFlag { } @FeatureFlagMarker - data object VibrateOnScan: FeatureFlag { + data object VibrateOnScan: FeatureFlag { override val key: String = "scan_debug_enabled" override val default: Boolean = false override val launched: Boolean = false @@ -28,7 +41,7 @@ sealed interface FeatureFlag { } @FeatureFlagMarker - data object WelcomeBonusBill: FeatureFlag { + data object WelcomeBonusBill: FeatureFlag { override val key: String = "welcome_bonus_bill_enabled" override val default: Boolean = true override val launched: Boolean = true @@ -37,7 +50,7 @@ sealed interface FeatureFlag { } @FeatureFlagMarker - data object TransactionDetails: FeatureFlag { + data object TransactionDetails: FeatureFlag { override val key: String = "transaction_details_enabled" override val default: Boolean = false override val launched: Boolean = false @@ -46,7 +59,7 @@ sealed interface FeatureFlag { } @FeatureFlagMarker - data object Pools: FeatureFlag { + data object Pools: FeatureFlag { override val key: String = "pools_enabled" override val default: Boolean = true override val launched: Boolean = true @@ -55,7 +68,7 @@ sealed interface FeatureFlag { } @FeatureFlagMarker - data object OnRamp: FeatureFlag { + data object OnRamp: FeatureFlag { override val key: String = "onramp_enabled" override val default: Boolean = true override val launched: Boolean = true @@ -64,7 +77,7 @@ sealed interface FeatureFlag { } @FeatureFlagMarker - data object BillCustomizer: FeatureFlag { + data object BillCustomizer: FeatureFlag { override val key: String = "bill_customizer_enabled" override val default: Boolean = true override val launched: Boolean = true @@ -73,7 +86,7 @@ sealed interface FeatureFlag { } @FeatureFlagMarker - data object CurrencyCreator: FeatureFlag { + data object CurrencyCreator: FeatureFlag { override val key: String = "currency_creator_enabled" override val default: Boolean = true override val launched: Boolean = true @@ -82,7 +95,7 @@ sealed interface FeatureFlag { } @FeatureFlagMarker - data object CashReservesEnabled: FeatureFlag { + data object CashReservesEnabled: FeatureFlag { override val key: String = "cash_reserves_enabled" override val default: Boolean = true override val launched: Boolean = true @@ -91,7 +104,7 @@ sealed interface FeatureFlag { } @FeatureFlagMarker - data object MarketCapChart: FeatureFlag { + data object MarketCapChart: FeatureFlag { override val key: String = "market_cap_chart_enabled" override val default: Boolean = true override val launched: Boolean = true @@ -100,7 +113,7 @@ sealed interface FeatureFlag { } @FeatureFlagMarker - data object CoinbaseOnRamp: FeatureFlag { + data object CoinbaseOnRamp: FeatureFlag { override val key: String = "coinbase_onramp_enabled" override val default: Boolean = true override val launched: Boolean = true @@ -109,7 +122,7 @@ sealed interface FeatureFlag { } @FeatureFlagMarker - data object CoinbaseOnRampSandbox: FeatureFlag { + data object CoinbaseOnRampSandbox: FeatureFlag { override val key: String = "coinbase_onramp_sandbox_enabled" override val default: Boolean = false override val launched: Boolean = false @@ -118,7 +131,7 @@ sealed interface FeatureFlag { } @FeatureFlagMarker - data object TokenDiscovery: FeatureFlag { + data object TokenDiscovery: FeatureFlag { override val key: String = "token_discovery_enabled" override val default: Boolean = true override val launched: Boolean = true @@ -127,7 +140,7 @@ sealed interface FeatureFlag { } @FeatureFlagMarker - data object BillTextures : FeatureFlag { + data object BillTextures : FeatureFlag { override val key: String = "bill_textures_enabled" override val default: Boolean = false override val launched: Boolean = false @@ -135,18 +148,29 @@ sealed interface FeatureFlag { override val persistLogOut: Boolean = false } + @FeatureFlagMarker + data object BackgroundReset : FeatureFlag { + override val key: String = "idle_reset" + override val default = BackgroundResetTimeout.FiveMinutes + override val launched: Boolean = false + override val visible: Boolean = true + override val persistLogOut: Boolean = false + override val options: List = BackgroundResetTimeout.entries + .map { FlagOption(it.name, it.label, isDisabled = it.duration == null) } + } + companion object { - val entries: List + val entries: List> get() = FeatureFlagEntries.entries - val availableEntries: List + val availableEntries: List> get() = entries .filterNot { it.launched } .filter { it.visible } } } -val FeatureFlag.title: String +val FeatureFlag<*>.title: String get() = when (this) { is FeatureFlag.CredentialManager -> "Credential Manager" FeatureFlag.VibrateOnScan -> "Vibrate on Scan" @@ -162,9 +186,10 @@ val FeatureFlag.title: String FeatureFlag.TokenDiscovery -> "Token Discovery" FeatureFlag.CurrencyCreator -> "Currency Creator" FeatureFlag.BillTextures -> "Bill Textures" + FeatureFlag.BackgroundReset -> "Background Reset" } -val FeatureFlag.message: String +val FeatureFlag<*>.message: String get() = when (this) { FeatureFlag.CredentialManager -> "When enabled, you will gain the ability to utilize Google's Password Manager for storing and recovering access keys for easier login experience" FeatureFlag.VibrateOnScan -> "When enabled, the device will vibrate once to indicate that the camera has registered the code on the bill" @@ -180,6 +205,7 @@ val FeatureFlag.message: String FeatureFlag.TokenDiscovery -> "When enabled, you'll gain access to leaderboards for tokens and discovery" FeatureFlag.CurrencyCreator -> "When enabled, you'll gain access to create new currencies" FeatureFlag.BillTextures -> "When enabled, you'll gain the ability to select textures for bills during currency creation" + FeatureFlag.BackgroundReset -> "Automatically returns the app to the camera screen after a period of inactivity with the app in the background" } 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 bfdb7aa8b..c2241d1e9 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 @@ -7,28 +7,33 @@ import kotlinx.coroutines.flow.StateFlow interface FeatureFlagController { fun enableBetaFeatures() fun observeOverride(): StateFlow - fun set(flag: FeatureFlag, value: Boolean) - suspend fun get(flag: FeatureFlag): Boolean + fun set(flag: FeatureFlag<*>, value: Boolean) + suspend fun get(flag: FeatureFlag<*>): Boolean fun observe(): StateFlow> - fun observe(flag: FeatureFlag): StateFlow - fun reset(flag: FeatureFlag) + fun observe(flag: FeatureFlag<*>): StateFlow + fun setOption(flag: FeatureFlag<*>, optionKey: String) + fun getOption(flag: FeatureFlag<*>): StateFlow + fun reset(flag: FeatureFlag<*>) fun reset() } object NoOpFeatureFlagController : FeatureFlagController { override fun enableBetaFeatures() = Unit override fun observeOverride(): StateFlow = MutableStateFlow(false) - override fun set(flag: FeatureFlag, value: Boolean) = Unit + override fun set(flag: FeatureFlag<*>, value: Boolean) = Unit - override suspend fun get(flag: FeatureFlag): Boolean = false + override suspend fun get(flag: FeatureFlag<*>): Boolean = false override fun observe(): StateFlow> = - MutableStateFlow(FeatureFlag.entries.map { BetaFeature(it, it.default) }) + MutableStateFlow(FeatureFlag.entries.map { BetaFeature(it, it.defaultEnabled) }) - override fun observe(flag: FeatureFlag): StateFlow = MutableStateFlow(false) + override fun observe(flag: FeatureFlag<*>): StateFlow = MutableStateFlow(false) - override fun reset(flag: FeatureFlag) = Unit + override fun setOption(flag: FeatureFlag<*>, optionKey: String) = Unit + override fun getOption(flag: FeatureFlag<*>): StateFlow = MutableStateFlow(flag.defaultOption) + + override fun reset(flag: FeatureFlag<*>) = Unit override fun reset() = Unit } -val LocalFeatureFlags = staticCompositionLocalOf { NoOpFeatureFlagController } \ No newline at end of file +val LocalFeatureFlags = staticCompositionLocalOf { NoOpFeatureFlagController } 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 02ffe72af..3de67c14d 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 @@ -6,6 +6,7 @@ import androidx.datastore.preferences.core.PreferenceDataStoreFactory import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.emptyPreferences +import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStoreFile import com.flipcash.app.featureflags.BetaFeature import com.flipcash.app.featureflags.FeatureFlag @@ -29,9 +30,12 @@ internal class InternalFeatureFlagController @Inject constructor( ) : FeatureFlagController { companion object { - private val FeatureFlag.booleanPreferenceKey + private val FeatureFlag<*>.booleanPreferenceKey get() = booleanPreferencesKey(key) + private val FeatureFlag<*>.optionPreferenceKey + get() = stringPreferencesKey("${key}_option") + private val betaOverrideKey = booleanPreferencesKey("beta_override") } @@ -73,7 +77,7 @@ internal class InternalFeatureFlagController @Inject constructor( betaFlags.data.map { prefs -> prefs[betaOverrideKey] ?: false } .stateIn(dataScope, SharingStarted.Eagerly, false) - override fun set(flag: FeatureFlag, value: Boolean) { + override fun set(flag: FeatureFlag<*>, value: Boolean) { dataScope.launch(Dispatchers.IO) { betaFlags.edit { prefs -> prefs[flag.booleanPreferenceKey] = value @@ -81,48 +85,88 @@ internal class InternalFeatureFlagController @Inject constructor( } } - override suspend fun get(flag: FeatureFlag): Boolean { + override suspend fun get(flag: FeatureFlag<*>): Boolean { return betaFlags.data.map { prefs -> - if (flag.launched) return@map flag.default - prefs[flag.booleanPreferenceKey] ?: flag.default - }.firstOrNull() ?: flag.default + if (flag.launched) return@map flag.defaultEnabled + if (flag.isOptionFlag) { + val option = prefs[flag.optionPreferenceKey] ?: flag.defaultOption + return@map flag.options.find { it.key == option }?.isDisabled != true + } + prefs[flag.booleanPreferenceKey] ?: flag.defaultEnabled + }.firstOrNull() ?: flag.defaultEnabled } override fun observe(): StateFlow> = betaFlags.data.map { prefs -> FeatureFlag.availableEntries - .map { - val value = if (it.launched) { - it.default + .map { flag -> + if (flag.isOptionFlag) { + val option = prefs[flag.optionPreferenceKey] ?: flag.defaultOption + BetaFeature(flag, enabled = flag.options.find { it.key == option }?.isDisabled != true, selectedOption = option) } else { - prefs[it.booleanPreferenceKey] ?: it.default + val value = if (flag.launched) { + flag.defaultEnabled + } else { + prefs[flag.booleanPreferenceKey] ?: flag.defaultEnabled + } + BetaFeature(flag, value) } - - BetaFeature(it, value) } }.stateIn( dataScope, started = SharingStarted.Eagerly, FeatureFlag.availableEntries - .map { BetaFeature(it, it.default) } + .map { flag -> + if (flag.isOptionFlag) { + BetaFeature(flag, enabled = flag.defaultEnabled, selectedOption = flag.defaultOption) + } else { + BetaFeature(flag, flag.defaultEnabled) + } + } ) - override fun observe(flag: FeatureFlag): StateFlow = betaFlags.data.map { prefs -> - if (flag.launched) return@map flag.default - prefs[flag.booleanPreferenceKey] ?: flag.default - }.stateIn(dataScope, started = SharingStarted.Eagerly, flag.default) + override fun observe(flag: FeatureFlag<*>): StateFlow = betaFlags.data.map { prefs -> + if (flag.launched) return@map flag.defaultEnabled + if (flag.isOptionFlag) { + val option = prefs[flag.optionPreferenceKey] ?: flag.defaultOption + return@map flag.options.find { it.key == option }?.isDisabled != true + } + prefs[flag.booleanPreferenceKey] ?: flag.defaultEnabled + }.stateIn(dataScope, started = SharingStarted.Eagerly, flag.defaultEnabled) + + override fun setOption(flag: FeatureFlag<*>, optionKey: String) { + dataScope.launch(Dispatchers.IO) { + betaFlags.edit { prefs -> + prefs[flag.optionPreferenceKey] = optionKey + } + } + } + + override fun getOption(flag: FeatureFlag<*>): StateFlow = betaFlags.data.map { prefs -> + prefs[flag.optionPreferenceKey] ?: flag.defaultOption + }.stateIn(dataScope, started = SharingStarted.Eagerly, flag.defaultOption) - override fun reset(flag: FeatureFlag) { + override fun reset(flag: FeatureFlag<*>) { dataScope.launch { - betaFlags.edit { it.remove(flag.booleanPreferenceKey) } + betaFlags.edit { + it.remove(flag.booleanPreferenceKey) + if (flag.isOptionFlag) { + it.remove(flag.optionPreferenceKey) + } + } } } override fun reset() { dataScope.launch { betaFlags.edit { prefs -> - FeatureFlag.entries.map { it to it.booleanPreferenceKey } - .filterNot { it.first.persistLogOut } - .onEach { prefs.remove(it.second) } + FeatureFlag.entries + .filterNot { it.persistLogOut } + .forEach { flag -> + prefs.remove(flag.booleanPreferenceKey) + if (flag.isOptionFlag) { + prefs.remove(flag.optionPreferenceKey) + } + } prefs.remove(betaOverrideKey) } diff --git a/apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/model/BackgroundResetTimeout.kt b/apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/model/BackgroundResetTimeout.kt new file mode 100644 index 000000000..d8832b607 --- /dev/null +++ b/apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/model/BackgroundResetTimeout.kt @@ -0,0 +1,13 @@ +package com.flipcash.app.featureflags.model + +import kotlin.time.Duration +import kotlin.time.Duration.Companion.minutes + +enum class BackgroundResetTimeout( + val duration: Duration?, + val label: String, +) { + OneMinute(1.minutes, "1 Minute"), + FiveMinutes(5.minutes, "5 Minutes"), + Never(null, "Never"); +} diff --git a/apps/flipcash/shared/ksp/src/main/kotlin/com/flipcash/app/ksp/FeatureFlagProcessor.kt b/apps/flipcash/shared/ksp/src/main/kotlin/com/flipcash/app/ksp/FeatureFlagProcessor.kt index a1352c696..24f1a6056 100644 --- a/apps/flipcash/shared/ksp/src/main/kotlin/com/flipcash/app/ksp/FeatureFlagProcessor.kt +++ b/apps/flipcash/shared/ksp/src/main/kotlin/com/flipcash/app/ksp/FeatureFlagProcessor.kt @@ -23,7 +23,7 @@ class FeatureFlagProcessor( import com.flipcash.app.featureflags.FeatureFlag.* object FeatureFlagEntries { - val entries: List = listOf( + val entries: List> = listOf( ${featureFlags.joinToString(",\n ") { it.substringAfterLast(".") }} ) } diff --git a/apps/flipcash/shared/menu/src/main/kotlin/com/flipcash/app/menu/MenuItem.kt b/apps/flipcash/shared/menu/src/main/kotlin/com/flipcash/app/menu/MenuItem.kt index 34e51468c..8bcc0421b 100644 --- a/apps/flipcash/shared/menu/src/main/kotlin/com/flipcash/app/menu/MenuItem.kt +++ b/apps/flipcash/shared/menu/src/main/kotlin/com/flipcash/app/menu/MenuItem.kt @@ -18,19 +18,19 @@ sealed interface MenuItem { val isStaffOnly: Boolean - val featureFlag: FeatureFlag? + val featureFlag: FeatureFlag<*>? } abstract class FullMenuItem( override val id: Any = UUID.randomUUID().toString(), override val isStaffOnly: Boolean = false, - override val featureFlag: FeatureFlag? = null + override val featureFlag: FeatureFlag<*>? = null ) : MenuItem abstract class StaffMenuItem( override val id: Any = UUID.randomUUID().toString(), override val isStaffOnly: Boolean = true, - override val featureFlag: FeatureFlag? = null + override val featureFlag: FeatureFlag<*>? = null ) : MenuItem diff --git a/ui/components/src/main/kotlin/com/getcode/ui/theme/CodeSegmentedControl.kt b/ui/components/src/main/kotlin/com/getcode/ui/theme/CodeSegmentedControl.kt index 186d02162..7e03d8927 100644 --- a/ui/components/src/main/kotlin/com/getcode/ui/theme/CodeSegmentedControl.kt +++ b/ui/components/src/main/kotlin/com/getcode/ui/theme/CodeSegmentedControl.kt @@ -5,6 +5,7 @@ import androidx.compose.material3.SingleChoiceSegmentedButtonRowScope import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import com.getcode.theme.CodeTheme import com.getcode.theme.White05 import com.getcode.theme.White10 import com.getcode.ui.components.SegmentedControl @@ -25,6 +26,8 @@ fun CodeSegmentedControl( modifier = modifier, colors = SegmentedButtonDefaults.colors( activeBorderColor = White10, + activeContentColor = CodeTheme.colors.textMain, + inactiveContentColor = CodeTheme.colors.textSecondary, activeContainerColor = White10, inactiveContainerColor = White05, inactiveBorderColor = White05,