From 3d620394cad752b7fa2babc7cd0956641fcb191e Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Fri, 8 May 2026 20:15:43 +0530 Subject: [PATCH] feat: adding search option on settings screen --- .../composeResources/values/strings.xml | 3 + .../app/features/settings/SettingsScreen.kt | 258 ++++- .../app/features/settings/SettingsSearch.kt | 971 ++++++++++++++++++ iosApp/Configuration/Version.xcconfig | 2 +- 4 files changed, 1199 insertions(+), 35 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index f235570b9..78461786c 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -391,6 +391,9 @@ Change to a different profile. Switch Profile Open Trakt connection screen + No settings found. + Search settings... + RESULTS Loading your Trakt lists… Choose where to save this title on Trakt Donate diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt index b625c9dc1..45a52febb 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt @@ -19,6 +19,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme @@ -29,10 +30,19 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveableStateHolder +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.Modifier +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.max @@ -66,8 +76,14 @@ import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepositor import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesUiState import nuvio.composeapp.generated.resources.Res import nuvio.composeapp.generated.resources.compose_settings_page_root +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource +private val SettingsSearchRevealThreshold = 28.dp +private const val SettingsSearchRevealAnimationMillis = 240L +private const val SettingsSearchRevealHapticDelayMillis = 90L + @Composable fun SettingsScreen( modifier: Modifier = Modifier, @@ -328,7 +344,66 @@ private fun MobileSettingsScreen( ) { val saveableStateHolder = rememberSaveableStateHolder() saveableStateHolder.SaveableStateProvider(page.name) { - NuvioScreen { + var settingsSearchQuery by rememberSaveable { mutableStateOf("") } + var rootSearchVisible by rememberSaveable { mutableStateOf(false) } + var rootSearchRevealAnimating by rememberSaveable { mutableStateOf(false) } + val listState = rememberLazyListState() + val hapticFeedback = LocalHapticFeedback.current + val hapticScope = rememberCoroutineScope() + val rootSearchRevealConnection = rememberSettingsRootSearchRevealConnection( + page = page, + listState = listState, + query = settingsSearchQuery, + searchVisible = rootSearchVisible, + ) { + rootSearchVisible = true + rootSearchRevealAnimating = true + hapticScope.launch { + delay(SettingsSearchRevealHapticDelayMillis) + hapticFeedback.performHapticFeedback(HapticFeedbackType.TextHandleMove) + } + } + val searchEntries = settingsSearchEntries( + pluginsEnabled = AppFeaturePolicy.pluginsEnabled, + liquidGlassNativeTabBarSupported = liquidGlassNativeTabBarSupported, + switchProfileAvailable = onSwitchProfile != null, + checkForUpdatesAvailable = onCheckForUpdatesClick != null, + ) + + fun openSearchTarget(target: SettingsSearchTarget) { + when (target) { + is SettingsSearchTarget.Page -> when (target.page) { + SettingsPage.Account -> onAccountClick() + SettingsPage.SupportersContributors -> onSupportersContributorsClick() + SettingsPage.ContinueWatching -> onContinueWatchingClick() + SettingsPage.Addons -> onAddonsClick() + SettingsPage.Plugins -> { + if (AppFeaturePolicy.pluginsEnabled) { + onPluginsClick() + } + } + SettingsPage.Homescreen -> onHomescreenClick() + SettingsPage.MetaScreen -> onMetaScreenClick() + else -> onPageChange(target.page) + } + SettingsSearchTarget.Downloads -> onDownloadsClick() + SettingsSearchTarget.Collections -> onCollectionsClick() + SettingsSearchTarget.SwitchProfile -> onSwitchProfile?.invoke() + SettingsSearchTarget.CheckForUpdates -> onCheckForUpdatesClick?.invoke() + } + } + + LaunchedEffect(rootSearchRevealAnimating) { + if (rootSearchRevealAnimating) { + delay(SettingsSearchRevealAnimationMillis) + rootSearchRevealAnimating = false + } + } + + NuvioScreen( + modifier = Modifier.nestedScroll(rootSearchRevealConnection), + listState = listState, + ) { stickyHeader { val previousPage = page.previousPage() NuvioScreenHeader( @@ -338,20 +413,33 @@ private fun MobileSettingsScreen( } when (page) { - SettingsPage.Root -> settingsRootContent( - isTablet = false, - onPlaybackClick = { onPageChange(SettingsPage.Playback) }, - onAppearanceClick = { onPageChange(SettingsPage.Appearance) }, - onNotificationsClick = { onPageChange(SettingsPage.Notifications) }, - onContentDiscoveryClick = { onPageChange(SettingsPage.ContentDiscovery) }, - onIntegrationsClick = { onPageChange(SettingsPage.Integrations) }, - onTraktClick = { onPageChange(SettingsPage.TraktAuthentication) }, - onSupportersContributorsClick = onSupportersContributorsClick, - onCheckForUpdatesClick = onCheckForUpdatesClick, - onDownloadsClick = onDownloadsClick, - onAccountClick = onAccountClick, - onSwitchProfileClick = onSwitchProfile, - ) + SettingsPage.Root -> { + settingsSearchRootContent( + query = settingsSearchQuery, + entries = searchEntries, + isTablet = false, + showSearchField = rootSearchVisible, + animateSearchField = rootSearchRevealAnimating, + onQueryChange = { settingsSearchQuery = it }, + onTargetClick = { openSearchTarget(it) }, + ) + if (settingsSearchQuery.isBlank()) { + settingsRootContent( + isTablet = false, + onPlaybackClick = { onPageChange(SettingsPage.Playback) }, + onAppearanceClick = { onPageChange(SettingsPage.Appearance) }, + onNotificationsClick = { onPageChange(SettingsPage.Notifications) }, + onContentDiscoveryClick = { onPageChange(SettingsPage.ContentDiscovery) }, + onIntegrationsClick = { onPageChange(SettingsPage.Integrations) }, + onTraktClick = { onPageChange(SettingsPage.TraktAuthentication) }, + onSupportersContributorsClick = onSupportersContributorsClick, + onCheckForUpdatesClick = onCheckForUpdatesClick, + onDownloadsClick = onDownloadsClick, + onAccountClick = onAccountClick, + onSwitchProfileClick = onSwitchProfile, + ) + } + } SettingsPage.Account -> accountSettingsContent( isTablet = false, ) @@ -453,6 +541,48 @@ private fun MobileSettingsScreen( } } +@Composable +private fun rememberSettingsRootSearchRevealConnection( + page: SettingsPage, + listState: LazyListState, + query: String, + searchVisible: Boolean, + onReveal: () -> Unit, +): NestedScrollConnection { + val revealThresholdPx = with(LocalDensity.current) { SettingsSearchRevealThreshold.toPx() } + val currentOnReveal by rememberUpdatedState(onReveal) + var pullDistancePx by remember(page) { mutableStateOf(0f) } + var revealTriggered by remember(page) { mutableStateOf(false) } + + return remember(page, listState, query, searchVisible, revealThresholdPx) { + object : NestedScrollConnection { + override fun onPostScroll( + consumed: Offset, + available: Offset, + source: NestedScrollSource, + ): Offset { + val isRootAtTop = page == SettingsPage.Root && + listState.firstVisibleItemIndex == 0 && + listState.firstVisibleItemScrollOffset == 0 + val canRevealSearch = isRootAtTop && !searchVisible && !revealTriggered && query.isBlank() + + if (canRevealSearch && available.y > 0f) { + pullDistancePx += available.y + if (pullDistancePx >= revealThresholdPx) { + pullDistancePx = 0f + revealTriggered = true + currentOnReveal() + } + } else if (!isRootAtTop || available.y < 0f) { + pullDistancePx = 0f + } + + return Offset.Zero + } + } + } +} + @Composable private fun TabletSettingsScreen( page: SettingsPage, @@ -559,11 +689,54 @@ private fun TabletSettingsScreen( } saveableStateHolder.SaveableStateProvider(page.name) { + var settingsSearchQuery by rememberSaveable { mutableStateOf("") } + var rootSearchVisible by rememberSaveable { mutableStateOf(false) } + var rootSearchRevealAnimating by rememberSaveable { mutableStateOf(false) } + val hapticFeedback = LocalHapticFeedback.current + val hapticScope = rememberCoroutineScope() + val searchEntries = settingsSearchEntries( + pluginsEnabled = AppFeaturePolicy.pluginsEnabled, + liquidGlassNativeTabBarSupported = liquidGlassNativeTabBarSupported, + switchProfileAvailable = onSwitchProfile != null, + checkForUpdatesAvailable = onCheckForUpdatesClick != null, + ) + + fun openSearchTarget(target: SettingsSearchTarget) { + when (target) { + is SettingsSearchTarget.Page -> openInlinePage(target.page) + SettingsSearchTarget.Downloads -> onDownloadsClick() + SettingsSearchTarget.Collections -> onCollectionsClick() + SettingsSearchTarget.SwitchProfile -> onSwitchProfile?.invoke() + SettingsSearchTarget.CheckForUpdates -> onCheckForUpdatesClick?.invoke() + } + } + val listState = rememberLazyListState() val bottomOverlayPadding = LocalNuvioBottomNavigationOverlayPadding.current + val rootSearchRevealConnection = rememberSettingsRootSearchRevealConnection( + page = page, + listState = listState, + query = settingsSearchQuery, + searchVisible = rootSearchVisible, + ) { + rootSearchVisible = true + rootSearchRevealAnimating = true + hapticScope.launch { + delay(SettingsSearchRevealHapticDelayMillis) + hapticFeedback.performHapticFeedback(HapticFeedbackType.TextHandleMove) + } + } + LaunchedEffect(rootSearchRevealAnimating) { + if (rootSearchRevealAnimating) { + delay(SettingsSearchRevealAnimationMillis) + rootSearchRevealAnimating = false + } + } LazyColumn( state = listState, - modifier = Modifier.fillMaxSize(), + modifier = Modifier + .fillMaxSize() + .nestedScroll(rootSearchRevealConnection), contentPadding = PaddingValues( start = 40.dp, top = topOffset, @@ -576,7 +749,11 @@ private fun TabletSettingsScreen( val previousPage = page.previousPage() TabletPageHeader( title = if (page == SettingsPage.Root) { - stringResource(activeCategory.labelRes) + if (settingsSearchQuery.isBlank()) { + stringResource(activeCategory.labelRes) + } else { + stringResource(Res.string.compose_settings_page_root) + } } else { stringResource(page.titleRes) }, @@ -585,23 +762,36 @@ private fun TabletSettingsScreen( ) } when (page) { - SettingsPage.Root -> settingsRootContent( - isTablet = true, - onPlaybackClick = { openInlinePage(SettingsPage.Playback) }, - onAppearanceClick = { openInlinePage(SettingsPage.Appearance) }, - onNotificationsClick = { openInlinePage(SettingsPage.Notifications) }, - onContentDiscoveryClick = { openInlinePage(SettingsPage.ContentDiscovery) }, - onIntegrationsClick = { openInlinePage(SettingsPage.Integrations) }, - onTraktClick = { openInlinePage(SettingsPage.TraktAuthentication) }, - onSupportersContributorsClick = { openInlinePage(SettingsPage.SupportersContributors) }, - onCheckForUpdatesClick = onCheckForUpdatesClick, - onDownloadsClick = onDownloadsClick, - onAccountClick = { openInlinePage(SettingsPage.Account) }, - onSwitchProfileClick = onSwitchProfile, - showAccountSection = activeCategory == SettingsCategory.Account, - showGeneralSection = activeCategory == SettingsCategory.General, - showAboutSection = activeCategory == SettingsCategory.About, - ) + SettingsPage.Root -> { + settingsSearchRootContent( + query = settingsSearchQuery, + entries = searchEntries, + isTablet = true, + showSearchField = rootSearchVisible, + animateSearchField = rootSearchRevealAnimating, + onQueryChange = { settingsSearchQuery = it }, + onTargetClick = { openSearchTarget(it) }, + ) + if (settingsSearchQuery.isBlank()) { + settingsRootContent( + isTablet = true, + onPlaybackClick = { openInlinePage(SettingsPage.Playback) }, + onAppearanceClick = { openInlinePage(SettingsPage.Appearance) }, + onNotificationsClick = { openInlinePage(SettingsPage.Notifications) }, + onContentDiscoveryClick = { openInlinePage(SettingsPage.ContentDiscovery) }, + onIntegrationsClick = { openInlinePage(SettingsPage.Integrations) }, + onTraktClick = { openInlinePage(SettingsPage.TraktAuthentication) }, + onSupportersContributorsClick = { openInlinePage(SettingsPage.SupportersContributors) }, + onCheckForUpdatesClick = onCheckForUpdatesClick, + onDownloadsClick = onDownloadsClick, + onAccountClick = { openInlinePage(SettingsPage.Account) }, + onSwitchProfileClick = onSwitchProfile, + showAccountSection = activeCategory == SettingsCategory.Account, + showGeneralSection = activeCategory == SettingsCategory.General, + showAboutSection = activeCategory == SettingsCategory.About, + ) + } + } SettingsPage.Account -> accountSettingsContent( isTablet = true, ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt new file mode 100644 index 000000000..1a8a2e56c --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt @@ -0,0 +1,971 @@ +package com.nuvio.app.features.settings + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.MutableTransitionState +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.slideInVertically +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.AccountCircle +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material.icons.rounded.CloudDownload +import androidx.compose.material.icons.rounded.CollectionsBookmark +import androidx.compose.material.icons.rounded.Extension +import androidx.compose.material.icons.rounded.Favorite +import androidx.compose.material.icons.rounded.Hub +import androidx.compose.material.icons.rounded.Home +import androidx.compose.material.icons.rounded.Language +import androidx.compose.material.icons.rounded.Link +import androidx.compose.material.icons.rounded.Notifications +import androidx.compose.material.icons.rounded.Palette +import androidx.compose.material.icons.rounded.People +import androidx.compose.material.icons.rounded.PlayArrow +import androidx.compose.material.icons.rounded.Search +import androidx.compose.material.icons.rounded.Style +import androidx.compose.material.icons.rounded.Tune +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.nuvio.app.isIos +import nuvio.composeapp.generated.resources.* +import org.jetbrains.compose.resources.stringResource + +internal sealed class SettingsSearchTarget { + data class Page(val page: SettingsPage) : SettingsSearchTarget() + object Downloads : SettingsSearchTarget() + object Collections : SettingsSearchTarget() + object SwitchProfile : SettingsSearchTarget() + object CheckForUpdates : SettingsSearchTarget() +} + +internal data class SettingsSearchEntry( + val key: String, + val title: String, + val description: String, + val page: String, + val section: String, + val category: String, + val icon: ImageVector, + val target: SettingsSearchTarget, +) { + val searchableText: String = listOf(title, description, page, section, category) + .joinToString(separator = " ") + .lowercase() + + val contextLabel: String = listOf(page, section) + .filter { it.isNotBlank() } + .distinct() + .joinToString(separator = " - ") +} + +@Composable +internal fun settingsSearchEntries( + pluginsEnabled: Boolean, + liquidGlassNativeTabBarSupported: Boolean, + switchProfileAvailable: Boolean, + checkForUpdatesAvailable: Boolean, +): List { + val accountCategory = stringResource(SettingsCategory.Account.labelRes) + val generalCategory = stringResource(SettingsCategory.General.labelRes) + val aboutCategory = stringResource(SettingsCategory.About.labelRes) + + val accountPage = stringResource(Res.string.compose_settings_page_account) + val traktPage = stringResource(Res.string.compose_settings_page_trakt) + val layoutPage = stringResource(Res.string.compose_settings_page_appearance) + val contentDiscoveryPage = stringResource(Res.string.compose_settings_page_content_discovery) + val downloadsPage = stringResource(Res.string.compose_settings_root_downloads_title) + val playbackPage = stringResource(Res.string.compose_settings_page_playback) + val integrationsPage = stringResource(Res.string.compose_settings_page_integrations) + val notificationsPage = stringResource(Res.string.compose_settings_page_notifications) + val supportersPage = stringResource(Res.string.compose_settings_page_supporters_contributors) + val homeLayoutPage = stringResource(Res.string.compose_settings_page_homescreen) + val detailPage = stringResource(Res.string.compose_settings_page_meta_screen) + val continueWatchingPage = stringResource(Res.string.compose_settings_page_continue_watching) + val posterStylePage = stringResource(Res.string.compose_settings_page_poster_customization) + val addonsPage = stringResource(Res.string.compose_settings_page_addons) + val pluginsPage = stringResource(Res.string.compose_settings_page_plugins) + val collectionsPage = stringResource(Res.string.collections_header) + val tmdbPage = stringResource(Res.string.compose_settings_page_tmdb_enrichment) + val mdbListPage = stringResource(Res.string.compose_settings_page_mdblist_ratings) + + val entries = mutableListOf() + + fun add( + key: String, + title: String, + description: String = "", + page: String = title, + section: String = "", + category: String = generalCategory, + icon: ImageVector, + target: SettingsSearchTarget, + ) { + entries += SettingsSearchEntry( + key = key, + title = title, + description = description, + page = page, + section = section, + category = category, + icon = icon, + target = target, + ) + } + + fun addPage( + page: SettingsPage, + key: String, + title: String, + description: String, + category: String = generalCategory, + icon: ImageVector, + ) { + add( + key = key, + title = title, + description = description, + page = title, + category = category, + icon = icon, + target = SettingsSearchTarget.Page(page), + ) + } + + fun addRow( + page: SettingsPage, + key: String, + title: String, + description: String = "", + pageLabel: String, + section: String, + category: String = generalCategory, + icon: ImageVector, + ) { + add( + key = key, + title = title, + description = description, + page = pageLabel, + section = section, + category = category, + icon = icon, + target = SettingsSearchTarget.Page(page), + ) + } + + if (switchProfileAvailable) { + add( + key = "switch-profile", + title = stringResource(Res.string.compose_settings_root_switch_profile_title), + description = stringResource(Res.string.compose_settings_root_switch_profile_description), + page = accountPage, + section = stringResource(Res.string.compose_settings_root_account_section), + category = accountCategory, + icon = Icons.Rounded.People, + target = SettingsSearchTarget.SwitchProfile, + ) + } + addPage( + page = SettingsPage.Account, + key = "account", + title = accountPage, + description = stringResource(Res.string.compose_settings_root_account_description), + category = accountCategory, + icon = Icons.Rounded.AccountCircle, + ) + addPage( + page = SettingsPage.TraktAuthentication, + key = "trakt", + title = traktPage, + description = stringResource(Res.string.compose_settings_root_trakt_description), + category = accountCategory, + icon = Icons.Rounded.Link, + ) + addPage( + page = SettingsPage.Appearance, + key = "layout", + title = layoutPage, + description = stringResource(Res.string.compose_settings_root_appearance_description), + icon = Icons.Rounded.Palette, + ) + addPage( + page = SettingsPage.ContentDiscovery, + key = "content-discovery", + title = contentDiscoveryPage, + description = stringResource(Res.string.compose_settings_root_content_discovery_description), + icon = Icons.Rounded.Extension, + ) + add( + key = "downloads", + title = downloadsPage, + description = stringResource(Res.string.compose_settings_root_downloads_description), + category = generalCategory, + icon = Icons.Rounded.CloudDownload, + target = SettingsSearchTarget.Downloads, + ) + addPage( + page = SettingsPage.Playback, + key = "playback", + title = playbackPage, + description = stringResource(Res.string.settings_playback_subtitle), + icon = Icons.Rounded.PlayArrow, + ) + addPage( + page = SettingsPage.Integrations, + key = "integrations", + title = integrationsPage, + description = stringResource(Res.string.compose_settings_root_integrations_description), + icon = Icons.Rounded.Link, + ) + addPage( + page = SettingsPage.Notifications, + key = "notifications", + title = notificationsPage, + description = stringResource(Res.string.compose_settings_root_notifications_description), + icon = Icons.Rounded.Notifications, + ) + addPage( + page = SettingsPage.SupportersContributors, + key = "supporters", + title = supportersPage, + description = stringResource(Res.string.about_supporters_contributors_subtitle), + category = aboutCategory, + icon = Icons.Rounded.Favorite, + ) + if (checkForUpdatesAvailable) { + add( + key = "check-updates", + title = stringResource(Res.string.compose_settings_root_check_updates_title), + description = stringResource(Res.string.compose_settings_root_check_updates_description), + page = supportersPage, + section = stringResource(Res.string.compose_settings_root_about_section), + category = aboutCategory, + icon = Icons.Rounded.CloudDownload, + target = SettingsSearchTarget.CheckForUpdates, + ) + } + + addRow( + page = SettingsPage.Account, + key = "account-status", + title = stringResource(Res.string.settings_account_status), + pageLabel = accountPage, + section = accountPage, + category = accountCategory, + icon = Icons.Rounded.AccountCircle, + ) + addRow( + page = SettingsPage.Account, + key = "account-sign-out", + title = stringResource(Res.string.settings_account_sign_out), + pageLabel = accountPage, + section = accountPage, + category = accountCategory, + icon = Icons.Rounded.AccountCircle, + ) + addRow( + page = SettingsPage.Account, + key = "account-delete", + title = stringResource(Res.string.settings_account_delete_account), + description = stringResource(Res.string.settings_account_delete_account_description), + pageLabel = accountPage, + section = accountPage, + category = accountCategory, + icon = Icons.Rounded.AccountCircle, + ) + + addRow( + page = SettingsPage.Appearance, + key = "theme", + title = stringResource(Res.string.settings_appearance_section_theme), + pageLabel = layoutPage, + section = stringResource(Res.string.settings_appearance_section_theme), + icon = Icons.Rounded.Palette, + ) + addRow( + page = SettingsPage.Appearance, + key = "amoled", + title = stringResource(Res.string.settings_appearance_amoled_black), + description = stringResource(Res.string.settings_appearance_amoled_description), + pageLabel = layoutPage, + section = stringResource(Res.string.settings_appearance_section_display), + icon = Icons.Rounded.Palette, + ) + if (liquidGlassNativeTabBarSupported) { + addRow( + page = SettingsPage.Appearance, + key = "liquid-glass", + title = stringResource(Res.string.settings_appearance_liquid_glass), + description = stringResource(Res.string.settings_appearance_liquid_glass_description), + pageLabel = layoutPage, + section = stringResource(Res.string.settings_appearance_section_display), + icon = Icons.Rounded.Palette, + ) + } + addRow( + page = SettingsPage.Appearance, + key = "app-language", + title = stringResource(Res.string.settings_appearance_app_language), + pageLabel = layoutPage, + section = stringResource(Res.string.settings_appearance_section_display), + icon = Icons.Rounded.Language, + ) + addPage( + page = SettingsPage.ContinueWatching, + key = "continue-watching", + title = continueWatchingPage, + description = stringResource(Res.string.settings_appearance_continue_watching_description), + icon = Icons.Rounded.Style, + ) + addPage( + page = SettingsPage.PosterCustomization, + key = "poster-card-style", + title = posterStylePage, + description = stringResource(Res.string.settings_appearance_poster_customization_description), + icon = Icons.Rounded.Tune, + ) + + addPage( + page = SettingsPage.Addons, + key = "addons", + title = addonsPage, + description = stringResource(Res.string.settings_content_discovery_addons_description), + icon = Icons.Rounded.Extension, + ) + if (pluginsEnabled) { + addPage( + page = SettingsPage.Plugins, + key = "plugins", + title = pluginsPage, + description = stringResource(Res.string.settings_content_discovery_plugins_description), + icon = Icons.Rounded.Hub, + ) + } + addPage( + page = SettingsPage.Homescreen, + key = "home-layout", + title = homeLayoutPage, + description = stringResource(Res.string.settings_content_discovery_homescreen_description), + icon = Icons.Rounded.Home, + ) + addPage( + page = SettingsPage.MetaScreen, + key = "detail-page", + title = detailPage, + description = stringResource(Res.string.settings_content_discovery_meta_screen_description), + icon = Icons.Rounded.Tune, + ) + add( + key = "collections", + title = collectionsPage, + description = stringResource(Res.string.settings_content_discovery_collections_description), + page = contentDiscoveryPage, + section = stringResource(Res.string.settings_content_discovery_section_home), + category = generalCategory, + icon = Icons.Rounded.CollectionsBookmark, + target = SettingsSearchTarget.Collections, + ) + + val playbackPlayer = stringResource(Res.string.settings_playback_section_player) + val playbackSubtitleAudio = stringResource(Res.string.settings_playback_section_subtitle_audio) + val playbackStreamSelection = stringResource(Res.string.settings_playback_section_stream_selection) + val playbackStreamAutoPlay = stringResource(Res.string.settings_playback_section_stream_auto_play) + val playbackDecoder = stringResource(Res.string.settings_playback_section_decoder) + val playbackSubtitleRendering = stringResource(Res.string.settings_playback_section_subtitle_rendering) + val playbackSkipSegments = stringResource(Res.string.settings_playback_section_skip_segments) + val playbackNextEpisode = stringResource(Res.string.settings_playback_section_next_episode) + addPlaybackRows( + addRow = ::addRow, + pageLabel = playbackPage, + section = playbackPlayer, + icon = Icons.Rounded.PlayArrow, + rows = listOf( + PlaybackSearchRow( + "loading-overlay", + stringResource(Res.string.settings_playback_show_loading_overlay), + stringResource(Res.string.settings_playback_show_loading_overlay_description), + ), + PlaybackSearchRow( + "hold-to-speed", + stringResource(Res.string.settings_playback_hold_to_speed), + stringResource(Res.string.settings_playback_hold_to_speed_description), + ), + PlaybackSearchRow("hold-speed", stringResource(Res.string.settings_playback_hold_speed)), + ), + ) + addPlaybackRows( + addRow = ::addRow, + pageLabel = playbackPage, + section = playbackSubtitleAudio, + icon = Icons.Rounded.PlayArrow, + rows = listOf( + PlaybackSearchRow("preferred-audio", stringResource(Res.string.settings_playback_preferred_audio_language)), + PlaybackSearchRow("secondary-audio", stringResource(Res.string.settings_playback_secondary_audio_language)), + PlaybackSearchRow("preferred-subtitles", stringResource(Res.string.settings_playback_preferred_subtitle_language)), + PlaybackSearchRow("secondary-subtitles", stringResource(Res.string.settings_playback_secondary_subtitle_language)), + ), + ) + addPlaybackRows( + addRow = ::addRow, + pageLabel = playbackPage, + section = playbackStreamSelection, + icon = Icons.Rounded.PlayArrow, + rows = listOf( + PlaybackSearchRow( + "reuse-last-link", + stringResource(Res.string.settings_playback_reuse_last_link), + stringResource(Res.string.settings_playback_reuse_last_link_description), + ), + PlaybackSearchRow("last-link-cache", stringResource(Res.string.settings_playback_last_link_cache_duration)), + ), + ) + addPlaybackRows( + addRow = ::addRow, + pageLabel = playbackPage, + section = playbackStreamAutoPlay, + icon = Icons.Rounded.PlayArrow, + rows = buildList { + add(PlaybackSearchRow("stream-mode", stringResource(Res.string.settings_playback_stream_selection_mode))) + add(PlaybackSearchRow("regex-pattern", stringResource(Res.string.settings_playback_regex_pattern))) + add(PlaybackSearchRow("stream-timeout", stringResource(Res.string.settings_playback_stream_timeout), stringResource(Res.string.settings_playback_stream_timeout_description))) + add(PlaybackSearchRow("source-scope", stringResource(Res.string.settings_playback_source_scope))) + add(PlaybackSearchRow("allowed-addons", stringResource(Res.string.settings_playback_allowed_addons))) + if (pluginsEnabled) add(PlaybackSearchRow("allowed-plugins", stringResource(Res.string.settings_playback_allowed_plugins))) + }, + ) + if (!isIos) { + addPlaybackRows( + addRow = ::addRow, + pageLabel = playbackPage, + section = playbackDecoder, + icon = Icons.Rounded.PlayArrow, + rows = listOf( + PlaybackSearchRow("decoder-priority", stringResource(Res.string.settings_playback_decoder_priority)), + PlaybackSearchRow("dv7-hevc", stringResource(Res.string.settings_playback_map_dv7_to_hevc), stringResource(Res.string.settings_playback_map_dv7_to_hevc_description)), + PlaybackSearchRow("tunneled-playback", stringResource(Res.string.settings_playback_tunneled_playback), stringResource(Res.string.settings_playback_tunneled_playback_description)), + ), + ) + addPlaybackRows( + addRow = ::addRow, + pageLabel = playbackPage, + section = playbackSubtitleRendering, + icon = Icons.Rounded.PlayArrow, + rows = listOf( + PlaybackSearchRow("libass", stringResource(Res.string.settings_playback_enable_libass), stringResource(Res.string.settings_playback_enable_libass_description)), + PlaybackSearchRow("libass-render", stringResource(Res.string.settings_playback_render_type)), + ), + ) + } + addPlaybackRows( + addRow = ::addRow, + pageLabel = playbackPage, + section = playbackSkipSegments, + icon = Icons.Rounded.PlayArrow, + rows = listOf( + PlaybackSearchRow("skip-intro", stringResource(Res.string.settings_playback_skip_intro_outro_recap), stringResource(Res.string.settings_playback_skip_intro_outro_recap_description)), + PlaybackSearchRow("anime-skip", stringResource(Res.string.settings_playback_anime_skip), stringResource(Res.string.settings_playback_anime_skip_description)), + PlaybackSearchRow("anime-skip-client", stringResource(Res.string.settings_playback_anime_skip_client_id), stringResource(Res.string.settings_playback_anime_skip_client_id_description)), + PlaybackSearchRow("intro-submit", stringResource(Res.string.settings_playback_intro_submit_enabled), stringResource(Res.string.settings_playback_intro_submit_enabled_description)), + PlaybackSearchRow("introdb-key", stringResource(Res.string.settings_playback_introdb_api_key), stringResource(Res.string.settings_playback_introdb_api_key_description)), + ), + ) + addPlaybackRows( + addRow = ::addRow, + pageLabel = playbackPage, + section = playbackNextEpisode, + icon = Icons.Rounded.PlayArrow, + rows = listOf( + PlaybackSearchRow("auto-play-next", stringResource(Res.string.settings_playback_auto_play_next_episode), stringResource(Res.string.settings_playback_auto_play_next_episode_description)), + PlaybackSearchRow("prefer-binge", stringResource(Res.string.settings_playback_prefer_binge_group), stringResource(Res.string.settings_playback_prefer_binge_group_description)), + PlaybackSearchRow("threshold-mode", stringResource(Res.string.settings_playback_threshold_mode)), + PlaybackSearchRow("threshold-percent", stringResource(Res.string.settings_playback_threshold_percentage), stringResource(Res.string.settings_playback_threshold_percentage_description)), + PlaybackSearchRow("threshold-minutes", stringResource(Res.string.settings_playback_minutes_before_end), stringResource(Res.string.settings_playback_minutes_before_end_description)), + ), + ) + + addContinueWatchingRows( + addRow = ::addRow, + pageLabel = continueWatchingPage, + section = stringResource(Res.string.settings_continue_watching_section_visibility), + icon = Icons.Rounded.Style, + rows = listOf( + PlaybackSearchRow( + "show-continue-watching", + stringResource(Res.string.settings_continue_watching_show_title), + stringResource(Res.string.settings_continue_watching_show_description), + ), + ), + ) + addContinueWatchingRows( + addRow = ::addRow, + pageLabel = continueWatchingPage, + section = stringResource(Res.string.settings_continue_watching_section_up_next_behavior), + icon = Icons.Rounded.Style, + rows = listOf( + PlaybackSearchRow("episode-thumbnails", stringResource(Res.string.settings_continue_watching_use_episode_thumbnails_title), stringResource(Res.string.settings_continue_watching_use_episode_thumbnails_description)), + PlaybackSearchRow("up-next", stringResource(Res.string.settings_continue_watching_up_next_title), stringResource(Res.string.settings_continue_watching_up_next_description)), + PlaybackSearchRow("unaired-next-up", stringResource(Res.string.settings_continue_watching_show_unaired_next_up_title), stringResource(Res.string.settings_continue_watching_show_unaired_next_up_description)), + PlaybackSearchRow("blur-next-up", stringResource(Res.string.settings_continue_watching_blur_next_up_title), stringResource(Res.string.settings_continue_watching_blur_next_up_description)), + ), + ) + addContinueWatchingRows( + addRow = ::addRow, + pageLabel = continueWatchingPage, + section = stringResource(Res.string.settings_continue_watching_section_on_launch), + icon = Icons.Rounded.Style, + rows = listOf( + PlaybackSearchRow("resume-prompt", stringResource(Res.string.settings_continue_watching_resume_prompt_title), stringResource(Res.string.settings_continue_watching_resume_prompt_description)), + ), + ) + + val posterSection = stringResource(Res.string.settings_poster_card_style) + listOf( + PlaybackSearchRow("poster-width", stringResource(Res.string.settings_poster_card_width)), + PlaybackSearchRow("poster-radius", stringResource(Res.string.settings_poster_card_radius)), + PlaybackSearchRow("poster-landscape", stringResource(Res.string.settings_poster_landscape_mode)), + PlaybackSearchRow("poster-hide-labels", stringResource(Res.string.settings_poster_hide_labels)), + ).forEach { row -> + addRow( + page = SettingsPage.PosterCustomization, + key = "poster-${row.key}", + title = row.title, + description = row.description, + pageLabel = posterStylePage, + section = posterSection, + icon = Icons.Rounded.Tune, + ) + } + + val homeLayoutSection = stringResource(Res.string.settings_homescreen_section_hero) + listOf( + PlaybackSearchRow("home-hero", stringResource(Res.string.settings_homescreen_show_hero), stringResource(Res.string.settings_homescreen_show_hero_description)), + PlaybackSearchRow("home-hide-unreleased", stringResource(Res.string.layout_hide_unreleased), stringResource(Res.string.layout_hide_unreleased_sub)), + PlaybackSearchRow("home-hero-sources", stringResource(Res.string.settings_homescreen_section_hero_sources)), + PlaybackSearchRow("home-catalogs", stringResource(Res.string.settings_homescreen_section_catalogs)), + ).forEach { row -> + addRow( + page = SettingsPage.Homescreen, + key = row.key, + title = row.title, + description = row.description, + pageLabel = homeLayoutPage, + section = homeLayoutSection, + icon = Icons.Rounded.Home, + ) + } + + val detailAppearanceSection = stringResource(Res.string.settings_meta_section_appearance) + listOf( + PlaybackSearchRow("meta-cinematic", stringResource(Res.string.settings_meta_cinematic_background), stringResource(Res.string.settings_meta_cinematic_background_description)), + PlaybackSearchRow("meta-tabs", stringResource(Res.string.settings_meta_tab_layout), stringResource(Res.string.settings_meta_tab_layout_description)), + PlaybackSearchRow("meta-episode-cards", stringResource(Res.string.settings_meta_episode_cards), stringResource(Res.string.settings_meta_episode_cards_description)), + PlaybackSearchRow("meta-blur-episodes", stringResource(Res.string.settings_meta_blur_unwatched_episodes), stringResource(Res.string.settings_meta_blur_unwatched_episodes_description)), + ).forEach { row -> + addRow( + page = SettingsPage.MetaScreen, + key = row.key, + title = row.title, + description = row.description, + pageLabel = detailPage, + section = detailAppearanceSection, + icon = Icons.Rounded.Tune, + ) + } + val detailSectionsSection = stringResource(Res.string.settings_meta_section_sections) + listOf( + PlaybackSearchRow("meta-overview", stringResource(Res.string.settings_meta_overview), stringResource(Res.string.settings_meta_overview_description)), + PlaybackSearchRow("meta-actions", stringResource(Res.string.settings_meta_actions), stringResource(Res.string.settings_meta_actions_description)), + PlaybackSearchRow("meta-details", stringResource(Res.string.settings_meta_details), stringResource(Res.string.settings_meta_details_description)), + PlaybackSearchRow("meta-trailers", stringResource(Res.string.settings_meta_trailers), stringResource(Res.string.settings_meta_trailers_description)), + PlaybackSearchRow("meta-cast", stringResource(Res.string.settings_meta_cast), stringResource(Res.string.settings_meta_cast_description)), + PlaybackSearchRow("meta-episodes", stringResource(Res.string.settings_meta_episodes), stringResource(Res.string.settings_meta_episodes_description)), + PlaybackSearchRow("meta-production", stringResource(Res.string.settings_meta_production), stringResource(Res.string.settings_meta_production_description)), + PlaybackSearchRow("meta-more-like-this", stringResource(Res.string.settings_meta_more_like_this), stringResource(Res.string.settings_meta_more_like_this_description)), + PlaybackSearchRow("meta-collection", stringResource(Res.string.settings_meta_collection), stringResource(Res.string.settings_meta_collection_description)), + PlaybackSearchRow("meta-comments", stringResource(Res.string.settings_meta_comments), stringResource(Res.string.settings_meta_comments_description)), + ).forEach { row -> + addRow( + page = SettingsPage.MetaScreen, + key = row.key, + title = row.title, + description = row.description, + pageLabel = detailPage, + section = detailSectionsSection, + icon = Icons.Rounded.Tune, + ) + } + + addPage( + page = SettingsPage.TmdbEnrichment, + key = "tmdb", + title = tmdbPage, + description = stringResource(Res.string.settings_integrations_tmdb_description), + icon = Icons.Rounded.Link, + ) + addPage( + page = SettingsPage.MdbListRatings, + key = "mdblist", + title = mdbListPage, + description = stringResource(Res.string.settings_integrations_mdblist_description), + icon = Icons.Rounded.Link, + ) + val tmdbModulesSection = stringResource(Res.string.settings_tmdb_section_modules) + listOf( + PlaybackSearchRow("tmdb-enable", stringResource(Res.string.settings_tmdb_enable_enrichment), stringResource(Res.string.settings_tmdb_enable_enrichment_description), stringResource(Res.string.settings_tmdb_section_title)), + PlaybackSearchRow("tmdb-api-key", stringResource(Res.string.settings_tmdb_personal_api_key), "", stringResource(Res.string.settings_tmdb_section_credentials)), + PlaybackSearchRow("tmdb-language", stringResource(Res.string.settings_tmdb_preferred_language), stringResource(Res.string.settings_tmdb_preferred_language_description), stringResource(Res.string.settings_tmdb_section_localization)), + PlaybackSearchRow("tmdb-trailers", stringResource(Res.string.settings_tmdb_module_trailers), stringResource(Res.string.settings_tmdb_module_trailers_description), tmdbModulesSection), + PlaybackSearchRow("tmdb-artwork", stringResource(Res.string.settings_tmdb_module_artwork), stringResource(Res.string.settings_tmdb_module_artwork_description), tmdbModulesSection), + PlaybackSearchRow("tmdb-basic-info", stringResource(Res.string.settings_tmdb_module_basic_info), stringResource(Res.string.settings_tmdb_module_basic_info_description), tmdbModulesSection), + PlaybackSearchRow("tmdb-details", stringResource(Res.string.settings_tmdb_module_details), stringResource(Res.string.settings_tmdb_module_details_description), tmdbModulesSection), + PlaybackSearchRow("tmdb-credits", stringResource(Res.string.settings_tmdb_module_credits), stringResource(Res.string.settings_tmdb_module_credits_description), tmdbModulesSection), + PlaybackSearchRow("tmdb-companies", stringResource(Res.string.settings_tmdb_module_production_companies), stringResource(Res.string.settings_tmdb_module_production_companies_description), tmdbModulesSection), + PlaybackSearchRow("tmdb-networks", stringResource(Res.string.settings_tmdb_module_networks), stringResource(Res.string.settings_tmdb_module_networks_description), tmdbModulesSection), + PlaybackSearchRow("tmdb-episodes", stringResource(Res.string.settings_tmdb_module_episodes), stringResource(Res.string.settings_tmdb_module_episodes_description), tmdbModulesSection), + PlaybackSearchRow("tmdb-season-posters", stringResource(Res.string.settings_tmdb_module_season_posters), stringResource(Res.string.settings_tmdb_module_season_posters_description), tmdbModulesSection), + PlaybackSearchRow("tmdb-more-like-this", stringResource(Res.string.settings_tmdb_module_more_like_this), stringResource(Res.string.settings_tmdb_module_more_like_this_description), tmdbModulesSection), + PlaybackSearchRow("tmdb-collections", stringResource(Res.string.settings_tmdb_module_collections), stringResource(Res.string.settings_tmdb_module_collections_description), tmdbModulesSection), + ).forEach { row -> + addRow( + page = SettingsPage.TmdbEnrichment, + key = row.key, + title = row.title, + description = row.description, + pageLabel = tmdbPage, + section = row.sectionOverride ?: tmdbModulesSection, + icon = Icons.Rounded.Link, + ) + } + + listOf( + PlaybackSearchRow("mdb-enable", stringResource(Res.string.settings_mdb_enable_ratings), stringResource(Res.string.settings_mdb_enable_ratings_description), stringResource(Res.string.settings_mdb_section_title)), + PlaybackSearchRow("mdb-api-key", stringResource(Res.string.settings_mdb_api_key_title), stringResource(Res.string.settings_mdb_api_key_description), stringResource(Res.string.settings_mdb_section_api_key)), + PlaybackSearchRow("mdb-imdb", stringResource(Res.string.source_imdb), "", stringResource(Res.string.settings_mdb_section_rating_providers)), + PlaybackSearchRow("mdb-tmdb", stringResource(Res.string.source_tmdb), "", stringResource(Res.string.settings_mdb_section_rating_providers)), + PlaybackSearchRow("mdb-tomatoes", stringResource(Res.string.source_rotten_tomatoes), "", stringResource(Res.string.settings_mdb_section_rating_providers)), + PlaybackSearchRow("mdb-metacritic", stringResource(Res.string.source_metacritic), "", stringResource(Res.string.settings_mdb_section_rating_providers)), + PlaybackSearchRow("mdb-trakt", stringResource(Res.string.source_trakt), "", stringResource(Res.string.settings_mdb_section_rating_providers)), + PlaybackSearchRow("mdb-letterboxd", stringResource(Res.string.source_letterboxd), "", stringResource(Res.string.settings_mdb_section_rating_providers)), + PlaybackSearchRow("mdb-audience", stringResource(Res.string.source_audience_score), "", stringResource(Res.string.settings_mdb_section_rating_providers)), + ).forEach { row -> + addRow( + page = SettingsPage.MdbListRatings, + key = row.key, + title = row.title, + description = row.description, + pageLabel = mdbListPage, + section = row.sectionOverride ?: stringResource(Res.string.settings_mdb_section_title), + icon = Icons.Rounded.Link, + ) + } + + val notificationsAlerts = stringResource(Res.string.settings_notifications_section_alerts) + addRow( + page = SettingsPage.Notifications, + key = "episode-release-alerts", + title = stringResource(Res.string.settings_notifications_episode_release_alerts), + description = stringResource(Res.string.settings_notifications_episode_release_alerts_description), + pageLabel = notificationsPage, + section = notificationsAlerts, + icon = Icons.Rounded.Notifications, + ) + addRow( + page = SettingsPage.Notifications, + key = "notification-test", + title = stringResource(Res.string.settings_notifications_test_title), + pageLabel = notificationsPage, + section = stringResource(Res.string.settings_notifications_section_test), + icon = Icons.Rounded.Notifications, + ) + + addRow( + page = SettingsPage.TraktAuthentication, + key = "trakt-authentication", + title = stringResource(Res.string.settings_trakt_authentication), + description = stringResource(Res.string.settings_trakt_intro_description), + pageLabel = traktPage, + section = stringResource(Res.string.settings_trakt_authentication), + category = accountCategory, + icon = Icons.Rounded.Link, + ) + listOf( + PlaybackSearchRow("trakt-library-source", stringResource(Res.string.trakt_library_source_title), stringResource(Res.string.trakt_library_source_subtitle)), + PlaybackSearchRow("trakt-watch-progress", stringResource(Res.string.trakt_watch_progress_title), stringResource(Res.string.trakt_watch_progress_subtitle)), + PlaybackSearchRow("trakt-continue-watching-window", stringResource(Res.string.trakt_continue_watching_window), stringResource(Res.string.trakt_continue_watching_subtitle)), + PlaybackSearchRow("trakt-comments", stringResource(Res.string.settings_trakt_comments), stringResource(Res.string.settings_trakt_comments_description)), + ).forEach { row -> + addRow( + page = SettingsPage.TraktAuthentication, + key = row.key, + title = row.title, + description = row.description, + pageLabel = traktPage, + section = stringResource(Res.string.settings_trakt_features), + category = accountCategory, + icon = Icons.Rounded.Link, + ) + } + + return entries +} + +private data class PlaybackSearchRow( + val key: String, + val title: String, + val description: String = "", + val sectionOverride: String? = null, +) + +private fun addPlaybackRows( + addRow: ( + page: SettingsPage, + key: String, + title: String, + description: String, + pageLabel: String, + section: String, + category: String, + icon: ImageVector, + ) -> Unit, + pageLabel: String, + section: String, + icon: ImageVector, + rows: List, +) { + rows.forEach { row -> + addRow( + SettingsPage.Playback, + "playback-${row.key}", + row.title, + row.description, + pageLabel, + section, + "", + icon, + ) + } +} + +private fun addContinueWatchingRows( + addRow: ( + page: SettingsPage, + key: String, + title: String, + description: String, + pageLabel: String, + section: String, + category: String, + icon: ImageVector, + ) -> Unit, + pageLabel: String, + section: String, + icon: ImageVector, + rows: List, +) { + rows.forEach { row -> + addRow( + SettingsPage.ContinueWatching, + "continue-watching-${row.key}", + row.title, + row.description, + pageLabel, + section, + "", + icon, + ) + } +} + +internal fun LazyListScope.settingsSearchRootContent( + query: String, + entries: List, + isTablet: Boolean, + showSearchField: Boolean, + animateSearchField: Boolean, + onQueryChange: (String) -> Unit, + onTargetClick: (SettingsSearchTarget) -> Unit, +) { + if (showSearchField || query.isNotBlank()) { + item(key = "settings-search-field") { + SettingsSearchRevealItem(animate = animateSearchField) { + SettingsSearchField( + query = query, + onQueryChange = onQueryChange, + ) + } + } + } + + if (query.isBlank()) return + + val results = settingsSearchResults( + query = query, + entries = entries, + ) + + item(key = "settings-search-results") { + if (results.isEmpty()) { + SettingsSearchEmptyState(isTablet = isTablet) + } else { + SettingsSection( + title = stringResource(Res.string.settings_search_results_section), + isTablet = isTablet, + ) { + SettingsGroup(isTablet = isTablet) { + results.forEachIndexed { index, entry -> + if (index > 0) { + SettingsGroupDivider(isTablet = isTablet) + } + SettingsNavigationRow( + title = entry.title, + description = entry.resultDescription(), + icon = entry.icon, + isTablet = isTablet, + onClick = { onTargetClick(entry.target) }, + ) + } + } + } + } + } +} + +@Composable +private fun SettingsSearchRevealItem( + animate: Boolean, + content: @Composable () -> Unit, +) { + if (!animate) { + content() + return + } + + val visibleState = remember { + MutableTransitionState(false).apply { + targetState = true + } + } + AnimatedVisibility( + visibleState = visibleState, + enter = expandVertically( + animationSpec = tween(durationMillis = 220), + expandFrom = Alignment.Top, + ) + fadeIn( + animationSpec = tween(durationMillis = 180), + ) + slideInVertically( + animationSpec = tween(durationMillis = 220), + initialOffsetY = { -it / 4 }, + ), + ) { + content() + } +} + +@Composable +private fun SettingsSearchField( + query: String, + onQueryChange: (String) -> Unit, +) { + OutlinedTextField( + value = query, + onValueChange = onQueryChange, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + shape = RoundedCornerShape(14.dp), + leadingIcon = { + Icon( + imageVector = Icons.Rounded.Search, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + trailingIcon = if (query.isNotBlank()) { + { + IconButton(onClick = { onQueryChange("") }) { + Icon( + imageVector = Icons.Rounded.Close, + contentDescription = stringResource(Res.string.compose_search_clear), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } else { + null + }, + placeholder = { + Text( + text = stringResource(Res.string.settings_search_placeholder), + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodyLarge, + ) + }, + textStyle = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.onSurface), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = MaterialTheme.colorScheme.outline, + unfocusedBorderColor = MaterialTheme.colorScheme.outline, + focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant, + unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant, + cursorColor = MaterialTheme.colorScheme.primary, + ), + ) +} + +@Composable +private fun SettingsSearchEmptyState(isTablet: Boolean) { + SettingsSection( + title = stringResource(Res.string.settings_search_results_section), + isTablet = isTablet, + ) { + SettingsGroup(isTablet = isTablet) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = if (isTablet) 20.dp else 16.dp, vertical = 18.dp), + ) { + Text( + text = stringResource(Res.string.settings_search_empty), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.Medium, + ) + } + } + } +} + +private fun settingsSearchResults( + query: String, + entries: List, +): List { + val terms = query + .trim() + .lowercase() + .split(Regex("\\s+")) + .filter { it.isNotBlank() } + + if (terms.isEmpty()) return emptyList() + + return entries.filter { entry -> + terms.all { term -> entry.searchableText.contains(term) } + } +} + +private fun SettingsSearchEntry.resultDescription(): String { + return description.ifBlank { contextLabel } +} diff --git a/iosApp/Configuration/Version.xcconfig b/iosApp/Configuration/Version.xcconfig index e29881bae..7e78cee85 100644 --- a/iosApp/Configuration/Version.xcconfig +++ b/iosApp/Configuration/Version.xcconfig @@ -1,3 +1,3 @@ CURRENT_PROJECT_VERSION=55 -MARKETING_VERSION=0.1.16 +MARKETING_VERSION=0.1.0