Move folder picker to settings behind feature flag (#3280)

* Refactor: Introduce a feature flag module

Introduces a new `core:featureflag` module to abstract feature flag implementations. This module provides a `FeatureFlag` interface and a `FeatureFlagFactory` to create feature flags backed by `RemoteConfig`.

Key changes include:
- Creating a new `:core:featureflag` Gradle module.
- Replacing direct `RemoteConfig` usage in `features/cover` and `features/review` with the new `FeatureFlag` abstraction.
- Using a `MemoryFeatureFlag` for testing purposes.

* Move folder picker to settings behind feature flag

This change introduces a feature flag `folder_picker_in_settings`.

When this flag is enabled:
- The folder picker icon is removed from the book overview top bar.
- A new entry to access the audiobook folder management screen is added to the settings menu.

Additionally, feature flag definitions have been centralized into a single `FeatureFlagBindingContainer` for better organization.

* Delete docs/feature-specs/folder_picker_in_settings.md
This commit is contained in:
Paul Woitaschek
2025-12-27 20:24:07 +01:00
committed by GitHub
parent 4e755b89cf
commit d3cbcc4179
19 changed files with 105 additions and 12 deletions

View File

@@ -73,5 +73,6 @@
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="RedundantCompanionReference" enabled="true" level="ERROR" enabled_by_default="true" editorAttributes="ERRORS_ATTRIBUTES" />
</profile>
</component>

View File

@@ -0,0 +1,41 @@
package voice.core.featureflag
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.BindingContainer
import dev.zacsweers.metro.ContributesTo
import dev.zacsweers.metro.Provides
import dev.zacsweers.metro.Qualifier
import dev.zacsweers.metro.SingleIn
@BindingContainer
@ContributesTo(AppScope::class)
object FeatureFlagBindingContainer {
@Provides
@SingleIn(AppScope::class)
@ReviewEnabledFeatureFlagQualifier
fun reviewEnabledFeatureFlag(factory: FeatureFlagFactory): FeatureFlag<Boolean> = factory.boolean("review_enabled", defaultValue = false)
@Provides
@SingleIn(AppScope::class)
@UserAgentFeatureFlagQualifier
fun userAgentFeatureFlag(factory: FeatureFlagFactory): FeatureFlag<String> {
return factory.string(key = "user_agent", defaultValue = "Mozilla/5.0")
}
@Provides
@SingleIn(AppScope::class)
@FolderPickerInSettingsFeatureFlagQualifier
fun folderPickerInSettingsFeatureFlag(factory: FeatureFlagFactory): FeatureFlag<Boolean> {
return factory.boolean(key = "folder_picker_in_settings")
}
}
@Qualifier
annotation class ReviewEnabledFeatureFlagQualifier
@Qualifier
annotation class UserAgentFeatureFlagQualifier
@Qualifier
annotation class FolderPickerInSettingsFeatureFlagQualifier

View File

@@ -6,9 +6,12 @@ import voice.core.remoteconfig.api.RemoteConfig
@Inject
class FeatureFlagFactory(private val remoteConfig: RemoteConfig) {
fun boolean(key: String): FeatureFlag<Boolean> {
fun boolean(
key: String,
defaultValue: Boolean = false,
): FeatureFlag<Boolean> {
return RemoteConfigFeatureFlag(remoteConfig = remoteConfig) {
it.boolean(key = key)
it.boolean(key = key, defaultValue = defaultValue)
}
}

View File

@@ -27,6 +27,7 @@
<string name="mark_as_current">Mark as Current</string>
<string name="mark_as_not_started">Mark as Not Started Yet</string>
<string name="audiobook_folders_title">Audiobook folders</string>
<string name="pref_audiobook_folders_explanation">Manage folders scanned for audiobooks</string>
<string name="playback_speed">Playback speed</string>
<string name="bookmark">Bookmark</string>
<string name="bookmark_edit_title">Edit bookmark</string>

View File

@@ -13,6 +13,7 @@ dependencies {
implementation(projects.core.playback)
implementation(projects.core.data.api)
implementation(projects.core.scanner)
implementation(projects.core.featureflag)
implementation(libs.lifecycle)
api(libs.immutable)

View File

@@ -25,6 +25,8 @@ import voice.core.data.repo.BookRepository
import voice.core.data.repo.internals.dao.RecentBookSearchDao
import voice.core.data.store.CurrentBookStore
import voice.core.data.store.GridModeStore
import voice.core.featureflag.FeatureFlag
import voice.core.featureflag.FolderPickerInSettingsFeatureFlagQualifier
import voice.core.playback.PlayerController
import voice.core.playback.playstate.PlayStateManager
import voice.core.scanner.DeviceHasStoragePermissionBug
@@ -53,6 +55,8 @@ class BookOverviewViewModel(
private val search: BookSearch,
private val contentRepo: BookContentRepo,
private val deviceHasStoragePermissionBug: DeviceHasStoragePermissionBug,
@FolderPickerInSettingsFeatureFlagQualifier
private val folderPickerInSettingsFeatureFlag: FeatureFlag<Boolean>,
) {
private val scope = MainScope()
@@ -123,6 +127,7 @@ class BookOverviewViewModel(
searchActive = searchActive,
searchViewState = bookSearchViewState,
showStoragePermissionBugCard = hasStoragePermissionBug,
showFolderPickerIcon = !folderPickerInSettingsFeatureFlag.get(),
)
}

View File

@@ -16,6 +16,7 @@ data class BookOverviewViewState(
val searchActive: Boolean,
val searchViewState: BookSearchViewState,
val showStoragePermissionBugCard: Boolean,
val showFolderPickerIcon: Boolean,
) {
companion object {
@@ -33,6 +34,7 @@ data class BookOverviewViewState(
query = "",
),
showStoragePermissionBugCard = false,
showFolderPickerIcon = true,
)
}

View File

@@ -276,6 +276,7 @@ internal class BookOverviewPreviewParameterProvider : PreviewParameterProvider<B
query = "",
),
showStoragePermissionBugCard = false,
showFolderPickerIcon = true,
),
)
}

View File

@@ -23,6 +23,7 @@ internal fun ColumnScope.BookOverviewSearchBar(
onSearchBookClick: (BookId) -> Unit,
searchActive: Boolean,
showAddBookHint: Boolean,
showFolderPickerIcon: Boolean,
searchViewState: BookSearchViewState,
) {
SearchBar(
@@ -47,6 +48,7 @@ internal fun ColumnScope.BookOverviewSearchBar(
TopBarTrailingIcon(
searchActive = searchActive,
showAddBookHint = showAddBookHint,
showFolderPickerIcon = showFolderPickerIcon,
onBookFolderClick = onBookFolderClick,
onSettingsClick = onSettingsClick,
)

View File

@@ -46,6 +46,7 @@ internal fun BookOverviewTopBar(
onSearchBookClick = onSearchBookClick,
searchActive = viewState.searchActive,
showAddBookHint = viewState.showAddBookHint,
showFolderPickerIcon = viewState.showFolderPickerIcon,
searchViewState = viewState.searchViewState,
)
var showLoading by remember { mutableStateOf(true) }
@@ -84,6 +85,7 @@ private fun BookOverviewTopBarPreview() {
query = "",
),
showStoragePermissionBugCard = false,
showFolderPickerIcon = true,
),
onBookFolderClick = {},
onSettingsClick = {},

View File

@@ -13,6 +13,7 @@ import voice.features.bookOverview.views.SettingsIcon
internal fun ColumnScope.TopBarTrailingIcon(
searchActive: Boolean,
showAddBookHint: Boolean,
showFolderPickerIcon: Boolean,
onBookFolderClick: () -> Unit,
onSettingsClick: () -> Unit,
) {
@@ -22,7 +23,9 @@ internal fun ColumnScope.TopBarTrailingIcon(
exit = fadeOut(),
) {
Row {
BookFolderIcon(withHint = showAddBookHint, onClick = onBookFolderClick)
if (showFolderPickerIcon) {
BookFolderIcon(withHint = showAddBookHint, onClick = onBookFolderClick)
}
SettingsIcon(onSettingsClick)
}
}

View File

@@ -13,7 +13,7 @@ import retrofit2.converter.kotlinx.serialization.asConverterFactory
import retrofit2.converter.scalars.ScalarsConverterFactory
import retrofit2.create
import voice.core.featureflag.FeatureFlag
import voice.core.featureflag.FeatureFlagFactory
import voice.core.featureflag.UserAgentFeatureFlagQualifier
@ContributesTo(AppScope::class)
@BindingContainer
@@ -52,11 +52,4 @@ object CoverModule {
.build()
.create()
}
@Provides
@SingleIn(AppScope::class)
@UserAgentFeatureFlagQualifier
fun userAgentFeatureFlag(factory: FeatureFlagFactory): FeatureFlag<String> {
return factory.string(key = "user_agent", defaultValue = "Mozilla/5.0")
}
}

View File

@@ -18,6 +18,7 @@ dependencies {
implementation(projects.core.data.api)
implementation(projects.core.documentfile)
implementation(projects.navigation)
implementation(projects.core.featureflag)
implementation(libs.datastore)
implementation(libs.coil)

View File

@@ -6,6 +6,7 @@ import kotlinx.coroutines.flow.first
import voice.core.data.repo.BookRepository
import voice.core.data.store.ReviewDialogShownStore
import voice.core.featureflag.FeatureFlag
import voice.core.featureflag.ReviewEnabledFeatureFlagQualifier
import voice.core.playback.playstate.PlayStateManager
import java.time.Clock
import java.time.temporal.ChronoUnit

View File

@@ -8,11 +8,11 @@ dependencies {
implementation(projects.core.common)
implementation(projects.navigation)
implementation(projects.core.strings)
implementation(projects.core.featureflag)
implementation(projects.core.playback)
implementation(projects.core.ui)
implementation(projects.core.data.api)
implementation(libs.androidxCore)
implementation(libs.androidxCore)
implementation(libs.material)
}

View File

@@ -20,6 +20,7 @@ interface SettingsListener {
fun setAutoSleepTimerStart(time: LocalTime)
fun setAutoSleepTimerEnd(time: LocalTime)
fun toggleAnalytics()
fun openFolderPicker()
companion object {
fun noop() = object : SettingsListener {
@@ -40,6 +41,7 @@ interface SettingsListener {
override fun setAutoSleepTimerStart(time: LocalTime) {}
override fun setAutoSleepTimerEnd(time: LocalTime) {}
override fun toggleAnalytics() {}
override fun openFolderPicker() {}
}
}
}

View File

@@ -21,6 +21,8 @@ import voice.core.data.store.DarkThemeStore
import voice.core.data.store.GridModeStore
import voice.core.data.store.SeekTimeStore
import voice.core.data.store.SleepTimerPreferenceStore
import voice.core.featureflag.FeatureFlag
import voice.core.featureflag.FolderPickerInSettingsFeatureFlagQualifier
import voice.core.ui.DARK_THEME_SETTABLE
import voice.core.ui.GridCount
import voice.navigation.Destination
@@ -44,6 +46,8 @@ class SettingsViewModel(
@AnalyticsConsentStore
private val analyticsConsentStore: DataStore<Boolean>,
private val gridCount: GridCount,
@FolderPickerInSettingsFeatureFlagQualifier
private val folderPickerInSettingsFeatureFlag: FeatureFlag<Boolean>,
dispatcherProvider: DispatcherProvider,
) : SettingsListener {
@@ -60,6 +64,9 @@ class SettingsViewModel(
initial = SleepTimerPreference.Default,
)
val analyticsEnabled by remember { analyticsConsentStore.data }.collectAsState(initial = false)
val showFolderPickerEntry = remember {
folderPickerInSettingsFeatureFlag.get()
}
return SettingsViewState(
useDarkTheme = useDarkTheme,
showDarkThemePref = DARK_THEME_SETTABLE,
@@ -79,6 +86,7 @@ class SettingsViewModel(
),
analyticsEnabled = analyticsEnabled,
showAnalyticSetting = appInfoProvider.analyticsIncluded,
showFolderPickerEntry = showFolderPickerEntry,
)
}
@@ -160,6 +168,10 @@ class SettingsViewModel(
navigator.goTo(Destination.Website("https://voice.woitaschek.de/faq/"))
}
override fun openFolderPicker() {
navigator.goTo(Destination.FolderPicker)
}
override fun setAutoSleepTimer(checked: Boolean) {
mainScope.launch {
sleepTimerPreferenceStore.updateData { currentPrefs ->

View File

@@ -13,6 +13,7 @@ data class SettingsViewState(
val autoSleepTimer: AutoSleepTimerViewState,
val showAnalyticSetting: Boolean,
val analyticsEnabled: Boolean,
val showFolderPickerEntry: Boolean,
) {
enum class Dialog {
@@ -33,6 +34,7 @@ data class SettingsViewState(
autoSleepTimer = AutoSleepTimerViewState.preview(),
analyticsEnabled = false,
showAnalyticSetting = true,
showFolderPickerEntry = false,
)
}
}

View File

@@ -6,6 +6,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.HelpOutline
import androidx.compose.material.icons.automirrored.outlined.ViewList
import androidx.compose.material.icons.outlined.Analytics
import androidx.compose.material.icons.outlined.Book
import androidx.compose.material.icons.outlined.BugReport
import androidx.compose.material.icons.outlined.Close
import androidx.compose.material.icons.outlined.GridView
@@ -81,6 +82,25 @@ private fun Settings(
},
) { contentPadding ->
LazyColumn(contentPadding = contentPadding) {
if (viewState.showFolderPickerEntry) {
item {
ListItem(
modifier = Modifier.clickable { listener.openFolderPicker() },
leadingContent = {
Icon(
imageVector = Icons.Outlined.Book,
contentDescription = stringResource(StringsR.string.audiobook_folders_title),
)
},
headlineContent = {
Text(stringResource(StringsR.string.audiobook_folders_title))
},
supportingContent = {
Text(stringResource(StringsR.string.pref_audiobook_folders_explanation))
},
)
}
}
if (viewState.showDarkThemePref) {
item {
DarkThemeRow(viewState.useDarkTheme, listener::toggleDarkTheme)