From 71961de4a267f75e83d66505aa0f8a61bf98180e Mon Sep 17 00:00:00 2001 From: guuilp Date: Thu, 9 Jul 2026 10:11:57 +0000 Subject: [PATCH 1/2] fix: per-profile isolation for Trakt auth and storage 1. TraktAuthStorage: Added profileId parameter to loadPayload(profileId) and savePayload(profileId, payload) in Android and iOS actual implementations, passing it explicitly to ProfileScopedKey.of(). 2. TraktAuthRepository: Added currentProfileId and profileGeneration for cross-profile operation detection. Methods now accept explicit profileId to isolate auth state per profile. loadFromDisk() and persist() use the scoped storage key. 3. TraktCredentialSync: Updated to pass profileId to currentStateForSync() and replaceStateFromSync(). 4. ProfileRepository/LibraryRepository: Updated calls to TraktAuthRepository.onProfileChanged(profileId). 5. WatchedRepository/WatchProgressRepository: Fixed pullFromServer/forceSnapshotRefreshFromServer to pass profileId to TraktAuthRepository.ensureLoaded() before checking activeOperationGeneration. 6. SyncManager: Passes profileId to ensureLoaded(). --- .../trakt/TraktAuthStorage.android.kt | 8 +- .../com/nuvio/app/core/sync/SyncManager.kt | 2 +- .../app/features/library/LibraryRepository.kt | 2 +- .../features/profiles/ProfileRepository.kt | 2 +- .../app/features/trakt/TraktAuthRepository.kt | 110 +++++++++--------- .../app/features/trakt/TraktAuthStorage.kt | 4 +- .../app/features/watched/WatchedRepository.kt | 6 +- .../features/trakt/TraktAuthStorage.ios.kt | 8 +- 8 files changed, 74 insertions(+), 68 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/trakt/TraktAuthStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/trakt/TraktAuthStorage.android.kt index cc8e8436..d694069b 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/trakt/TraktAuthStorage.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/trakt/TraktAuthStorage.android.kt @@ -14,13 +14,13 @@ internal actual object TraktAuthStorage { preferences = context.getSharedPreferences(preferencesName, Context.MODE_PRIVATE) } - actual fun loadPayload(): String? = - preferences?.getString(ProfileScopedKey.of(payloadKey), null) + actual fun loadPayload(profileId: Int): String? = + preferences?.getString(ProfileScopedKey.of(payloadKey, profileId), null) - actual fun savePayload(payload: String) { + actual fun savePayload(profileId: Int, payload: String) { preferences ?.edit() - ?.putString(ProfileScopedKey.of(payloadKey), payload) + ?.putString(ProfileScopedKey.of(payloadKey, profileId), payload) ?.apply() } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/SyncManager.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/SyncManager.kt index f81af882..703b88c4 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/SyncManager.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/SyncManager.kt @@ -427,7 +427,7 @@ object SyncManager { continue } - TraktAuthRepository.ensureLoaded() + TraktAuthRepository.ensureLoaded(profileId) TraktSettingsRepository.ensureLoaded() val traktAuthenticated = TraktAuthRepository.isAuthenticated.value diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryRepository.kt index 06c102f8..cb9fbebe 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryRepository.kt @@ -151,7 +151,7 @@ object LibraryRepository { TraktSettingsRepository.onProfileChanged() if (!loadFromDisk(profileId)) return - TraktAuthRepository.onProfileChanged() + TraktAuthRepository.onProfileChanged(profileId) TraktLibraryRepository.onProfileChanged() if (TraktAuthRepository.isAuthenticated.value) { TraktLibraryRepository.preloadListTabsAsync() diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt index fd76b3fe..7944af4c 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt @@ -154,7 +154,7 @@ object ProfileRepository { persist() WatchedRepository.onProfileChanged(profileIndex) TraktSettingsRepository.onProfileChanged() - TraktAuthRepository.onProfileChanged() + TraktAuthRepository.onProfileChanged(profileIndex) LibraryRepository.onProfileChanged(profileIndex) WatchProgressRepository.onProfileChanged(profileIndex) AddonRepository.onProfileChanged(profileIndex) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktAuthRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktAuthRepository.kt index 20fa9eb5..a9e3308e 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktAuthRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktAuthRepository.kt @@ -48,33 +48,37 @@ object TraktAuthRepository { val isAuthenticated: StateFlow = _isAuthenticated.asStateFlow() private var hasLoaded = false + private var currentProfileId: Int = 1 + private var profileGeneration: Long = 0L private var authState = TraktAuthState() - fun ensureLoaded() { - if (hasLoaded) return - loadFromDisk() + fun ensureLoaded(profileId: Int = ProfileRepository.activeProfileId) { + if (hasLoaded && currentProfileId == profileId) return + loadFromDisk(profileId) } - fun onProfileChanged() { - loadFromDisk() + fun onProfileChanged(profileId: Int) { + loadFromDisk(profileId) } fun clearLocalState() { hasLoaded = false + currentProfileId = 1 + profileGeneration += 1L authState = TraktAuthState() publish() } - fun snapshot(): TraktAuthUiState { - ensureLoaded() + fun snapshot(profileId: Int = ProfileRepository.activeProfileId): TraktAuthUiState { + ensureLoaded(profileId) return _uiState.value } fun hasRequiredCredentials(): Boolean = TraktConfig.CLIENT_ID.isNotBlank() && TraktConfig.CLIENT_SECRET.isNotBlank() - fun onConnectRequested(): String? { - ensureLoaded() + fun onConnectRequested(profileId: Int = ProfileRepository.activeProfileId): String? { + ensureLoaded(profileId) if (!hasRequiredCredentials()) { publish(errorMessage = localizedString(Res.string.trakt_missing_credentials)) return null @@ -85,7 +89,7 @@ object TraktAuthRepository { pendingAuthorizationState = oauthState, pendingAuthorizationStartedAtMillis = TraktPlatformClock.nowEpochMs(), ) - persist() + persist(profileId) publish( statusMessage = localizedString(Res.string.trakt_complete_sign_in_browser), errorMessage = null, @@ -94,21 +98,21 @@ object TraktAuthRepository { return buildAuthorizationUrl(oauthState) } - fun pendingAuthorizationUrl(): String? { - ensureLoaded() + fun pendingAuthorizationUrl(profileId: Int = ProfileRepository.activeProfileId): String? { + ensureLoaded(profileId) val oauthState = authState.pendingAuthorizationState ?: return null return buildAuthorizationUrl(oauthState) } - fun onCancelAuthorization() { - ensureLoaded() + fun onCancelAuthorization(profileId: Int = ProfileRepository.activeProfileId) { + ensureLoaded(profileId) clearPendingAuthorization() - persist() + persist(profileId) publish(statusMessage = null, errorMessage = null) } - fun onCancelDeviceFlow() { - onCancelAuthorization() + fun onCancelDeviceFlow(profileId: Int = ProfileRepository.activeProfileId) { + onCancelAuthorization(profileId) } fun onAuthLaunchFailed(reason: String) { @@ -116,7 +120,8 @@ object TraktAuthRepository { } fun onAuthCallbackReceived(callbackUrl: String) { - ensureLoaded() + val profileId = ProfileRepository.activeProfileId + ensureLoaded(profileId) if (!callbackUrl.startsWith("${TraktConfig.REDIRECT_URI}?", ignoreCase = true) && !callbackUrl.equals(TraktConfig.REDIRECT_URI, ignoreCase = true) ) { @@ -124,15 +129,15 @@ object TraktAuthRepository { } scope.launch { - completeAuthorizationFromCallback(callbackUrl) + completeAuthorizationFromCallback(callbackUrl, profileId) } } - suspend fun authorizedHeaders(): Map? { - ensureLoaded() + suspend fun authorizedHeaders(profileId: Int = currentProfileId): Map? { + ensureLoaded(profileId) if (!authState.isAuthenticated) return null - val hasValidToken = refreshTokenIfNeeded(force = false) + val hasValidToken = refreshTokenIfNeeded(force = false, profileId = profileId) if (!hasValidToken) return null val accessToken = authState.accessToken?.trim().orEmpty() @@ -145,9 +150,9 @@ object TraktAuthRepository { ) } - suspend fun refreshUserSettings(): String? { - ensureLoaded() - val headers = authorizedHeaders() ?: return null + suspend fun refreshUserSettings(profileId: Int = currentProfileId): String? { + ensureLoaded(profileId) + val headers = authorizedHeaders(profileId) ?: return null val response = runCatching { httpGetTextWithHeaders( url = "$BASE_URL/users/settings", @@ -166,19 +171,19 @@ object TraktAuthRepository { username = parsed.user?.username, userSlug = parsed.user?.ids?.slug, ) - persist() + persist(profileId) publish() return authState.username } - fun onDisconnectRequested() { - ensureLoaded() + fun onDisconnectRequested(profileId: Int = currentProfileId) { + ensureLoaded(profileId) scope.launch { - disconnect() + disconnect(profileId) } } - private suspend fun completeAuthorizationFromCallback(callbackUrl: String) { + private suspend fun completeAuthorizationFromCallback(callbackUrl: String, profileId: Int = currentProfileId) { publish(isLoading = true, errorMessage = null) val parsedUrl = runCatching { Url(callbackUrl) } @@ -189,7 +194,7 @@ object TraktAuthRepository { if (parsedUrl == null) { clearPendingAuthorization() - persist() + persist(profileId) publish( isLoading = false, errorMessage = localizedString(Res.string.trakt_invalid_callback), @@ -202,7 +207,7 @@ object TraktAuthRepository { val errorDescription = parsedUrl.parameters["error_description"] ?: localizedString(Res.string.trakt_authorization_denied) clearPendingAuthorization() - persist() + persist(profileId) publish( isLoading = false, errorMessage = errorDescription, @@ -213,7 +218,7 @@ object TraktAuthRepository { val code = parsedUrl.parameters["code"].orEmpty().trim() if (code.isBlank()) { clearPendingAuthorization() - persist() + persist(profileId) publish( isLoading = false, errorMessage = localizedString(Res.string.trakt_missing_auth_code), @@ -225,7 +230,7 @@ object TraktAuthRepository { val callbackState = parsedUrl.parameters["state"].orEmpty().trim() if (!expectedState.isNullOrBlank() && callbackState != expectedState) { clearPendingAuthorization() - persist() + persist(profileId) publish( isLoading = false, errorMessage = localizedString(Res.string.trakt_invalid_callback_state), @@ -233,10 +238,10 @@ object TraktAuthRepository { return } - exchangeAuthorizationCode(code) + exchangeAuthorizationCode(code, profileId) } - private suspend fun exchangeAuthorizationCode(code: String) { + private suspend fun exchangeAuthorizationCode(code: String, profileId: Int = currentProfileId) { val body = json.encodeToString( TraktAuthorizationCodeRequest( code = code, @@ -259,7 +264,7 @@ object TraktAuthRepository { if (response == null) { clearPendingAuthorization() - persist() + persist(profileId) publish(isLoading = false, errorMessage = localizedString(Res.string.trakt_sign_in_complete_failed)) return } @@ -270,7 +275,7 @@ object TraktAuthRepository { if (parsed == null) { clearPendingAuthorization() - persist() + persist(profileId) publish(isLoading = false, errorMessage = localizedString(Res.string.trakt_invalid_token_response)) return } @@ -284,8 +289,8 @@ object TraktAuthRepository { pendingAuthorizationState = null, pendingAuthorizationStartedAtMillis = null, ) - persist() - refreshUserSettings() + persist(profileId) + refreshUserSettings(profileId) publish( isLoading = false, statusMessage = localizedString(Res.string.trakt_connected_status), @@ -293,7 +298,7 @@ object TraktAuthRepository { ) } - private suspend fun disconnect() { + private suspend fun disconnect(profileId: Int = currentProfileId) { publish(isLoading = true, errorMessage = null) val token = authState.accessToken?.takeIf { it.isNotBlank() } @@ -317,9 +322,9 @@ object TraktAuthRepository { } } - TraktCredentialSync.deleteRemote() + TraktCredentialSync.deleteRemote(profileId) authState = TraktAuthState() - persist() + persist(profileId) publish( isLoading = false, statusMessage = localizedString(Res.string.trakt_disconnected_status), @@ -327,9 +332,8 @@ object TraktAuthRepository { ) } - private suspend fun refreshTokenIfNeeded(force: Boolean): Boolean = refreshMutex.withLock { + private suspend fun refreshTokenIfNeeded(force: Boolean, profileId: Int = currentProfileId): Boolean = refreshMutex.withLock { if (!hasRequiredCredentials()) return@withLock false - val profileId = ProfileRepository.activeProfileId val refreshToken = authState.refreshToken?.takeIf { it.isNotBlank() } ?: return@withLock false @@ -391,14 +395,14 @@ object TraktAuthRepository { createdAt = parsed.createdAt, expiresIn = parsed.expiresIn, ) - persist() + persist(profileId) publish() true } private suspend fun invalidateCredentials(profileId: Int) { authState = TraktAuthState() - persist() + persist(profileId) publish( isLoading = false, statusMessage = null, @@ -407,9 +411,11 @@ object TraktAuthRepository { TraktCredentialSync.deleteRemote(profileId) } - private fun loadFromDisk() { + private fun loadFromDisk(profileId: Int) { + currentProfileId = profileId + profileGeneration += 1L hasLoaded = true - val payload = TraktAuthStorage.loadPayload().orEmpty().trim() + val payload = TraktAuthStorage.loadPayload(profileId).orEmpty().trim() authState = if (payload.isBlank()) { TraktAuthState() } else { @@ -460,8 +466,8 @@ object TraktAuthRepository { ) } - private fun persist() { - TraktAuthStorage.savePayload(json.encodeToString(authState)) + private fun persist(profileId: Int = currentProfileId) { + TraktAuthStorage.savePayload(profileId, json.encodeToString(authState)) } private fun buildAuthorizationUrl(state: String): String { @@ -486,6 +492,7 @@ object TraktAuthRepository { return nowSeconds >= (expiresAtSeconds - 60) } + private fun localizedString(resource: StringResource): String = runBlocking { getString(resource) } } internal enum class TraktTokenRefreshResponseAction { @@ -549,4 +556,3 @@ private data class TraktUserDto( private data class TraktUserIdsDto( val slug: String? = null, ) - private fun localizedString(resource: StringResource): String = runBlocking { getString(resource) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktAuthStorage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktAuthStorage.kt index 3527be20..12cb2420 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktAuthStorage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktAuthStorage.kt @@ -1,6 +1,6 @@ package com.nuvio.app.features.trakt internal expect object TraktAuthStorage { - fun loadPayload(): String? - fun savePayload(payload: String) + fun loadPayload(profileId: Int): String? + fun savePayload(profileId: Int, payload: String) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedRepository.kt index 27ee2076..cb2cae03 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedRepository.kt @@ -270,7 +270,7 @@ object WatchedRepository { ) suspend fun pullFromServer(profileId: Int) { - TraktAuthRepository.ensureLoaded() + TraktAuthRepository.ensureLoaded(profileId) TraktSettingsRepository.ensureLoaded() refreshForSource( profileId = profileId, @@ -283,7 +283,7 @@ object WatchedRepository { } suspend fun forceSnapshotRefreshFromServer(profileId: Int) { - TraktAuthRepository.ensureLoaded() + TraktAuthRepository.ensureLoaded(profileId) TraktSettingsRepository.ensureLoaded() refreshForSource( profileId = profileId, @@ -300,7 +300,7 @@ object WatchedRepository { source: WatchProgressSource, forceSnapshot: Boolean = true, ): Boolean { - TraktAuthRepository.ensureLoaded() + TraktAuthRepository.ensureLoaded(profileId) TraktSettingsRepository.ensureLoaded() if (ProfileRepository.activeProfileId != profileId) { log.d { "Skipping watched refresh for inactive profile $profileId" } diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/trakt/TraktAuthStorage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/trakt/TraktAuthStorage.ios.kt index 3ccbaff4..916fedb0 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/trakt/TraktAuthStorage.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/trakt/TraktAuthStorage.ios.kt @@ -6,10 +6,10 @@ import platform.Foundation.NSUserDefaults internal actual object TraktAuthStorage { private const val payloadKey = "trakt_auth_payload" - actual fun loadPayload(): String? = - NSUserDefaults.standardUserDefaults.stringForKey(ProfileScopedKey.of(payloadKey)) + actual fun loadPayload(profileId: Int): String? = + NSUserDefaults.standardUserDefaults.stringForKey(ProfileScopedKey.of(payloadKey, profileId)) - actual fun savePayload(payload: String) { - NSUserDefaults.standardUserDefaults.setObject(payload, forKey = ProfileScopedKey.of(payloadKey)) + actual fun savePayload(profileId: Int, payload: String) { + NSUserDefaults.standardUserDefaults.setObject(payload, forKey = ProfileScopedKey.of(payloadKey, profileId)) } } From 79269f461a3b79ca7447effb2185da932f743fa3 Mon Sep 17 00:00:00 2001 From: YLaskco <2punksdnb@gmail.com> Date: Thu, 23 Jul 2026 10:18:39 -0400 Subject: [PATCH 2/2] feat(library): search the cloud library The Cloud view only had provider and type filters, so finding a specific release in a large debrid account meant scrolling the whole list, even though the item names and file names are already loaded on the client. Adds a local search field under the toolbar that narrows the already loaded items. It matches the item name or any of its file names, since the useful identifier is often in the filename rather than the title, and it stacks on top of the existing provider and type filters. --- .../composeResources/values/strings.xml | 1 + .../app/features/library/LibraryScreen.kt | 64 ++++++++++++++++++- 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 1e20233a..34830c9b 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -1657,6 +1657,7 @@ %1$d playable files All Refresh cloud library + Search cloud library Select provider Select type Ready to play diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt index ae561f6c..b5b2682d 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt @@ -31,15 +31,18 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.InsertDriveFile import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.rounded.Close import androidx.compose.material.icons.rounded.GridView import androidx.compose.material.icons.rounded.PlayArrow import androidx.compose.material.icons.rounded.Refresh +import androidx.compose.material.icons.rounded.Search import androidx.compose.material.icons.rounded.ViewAgenda import com.nuvio.app.core.ui.NuvioLoadingIndicator import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -132,6 +135,7 @@ fun LibraryScreen( } var selectedProviderId by rememberSaveable { mutableStateOf(null) } var selectedTypeName by rememberSaveable { mutableStateOf(null) } + var cloudSearchQuery by rememberSaveable { mutableStateOf("") } val selectedType = remember(selectedTypeName) { selectedTypeName?.let { runCatching { CloudLibraryItemType.valueOf(it) }.getOrNull() } } @@ -305,6 +309,11 @@ fun LibraryScreen( selectedProviderId = selectedProviderId, selectedType = selectedType, selectedCloudItemKey = selectedCloudItemKey, + searchQuery = cloudSearchQuery, + onSearchQueryChange = { + cloudSearchQuery = it + selectedCloudItemKey = null + }, onProviderSelected = { selectedProviderId = it selectedTypeName = null @@ -443,6 +452,8 @@ private fun LazyListScope.cloudLibraryContent( selectedProviderId: String?, selectedType: CloudLibraryItemType?, selectedCloudItemKey: String?, + searchQuery: String, + onSearchQueryChange: (String) -> Unit, onProviderSelected: (String?) -> Unit, onTypeSelected: (CloudLibraryItemType?) -> Unit, onItemSelected: (CloudLibraryItem) -> Unit, @@ -488,8 +499,19 @@ private fun LazyListScope.cloudLibraryContent( .distinct() .sortedBy { type -> type.ordinal } val effectiveSelectedType = selectedType?.takeIf { type -> type in availableTypes } - val filteredItems = providerItems + val typeFilteredItems = providerItems .filter { item -> effectiveSelectedType == null || item.type == effectiveSelectedType } + // Local filter over the already-loaded library. Matches the item name or any of its + // file names, since the useful identifier is often in the filename, not the title. + val trimmedQuery = searchQuery.trim() + val filteredItems = if (trimmedQuery.isEmpty()) { + typeFilteredItems + } else { + typeFilteredItems.filter { item -> + item.name.contains(trimmedQuery, ignoreCase = true) || + item.files.any { file -> file.name.contains(trimmedQuery, ignoreCase = true) } + } + } val selectedItem = filteredItems.firstOrNull { it.stableKey == selectedCloudItemKey } if (selectedItem != null) { @@ -514,6 +536,14 @@ private fun LazyListScope.cloudLibraryContent( ) } + item(key = "cloud-library-search") { + CloudLibrarySearchField( + query = searchQuery, + onQueryChange = onSearchQueryChange, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) + } + uiState.providers .filter { providerState -> selectedProviderId == null || providerState.providerId == selectedProviderId } .filter { providerState -> !providerState.errorMessage.isNullOrBlank() && providerState.items.isEmpty() } @@ -557,6 +587,38 @@ private fun LazyListScope.cloudLibraryContent( } } +@Composable +private fun CloudLibrarySearchField( + query: String, + onQueryChange: (String) -> Unit, + modifier: Modifier = Modifier, +) { + OutlinedTextField( + value = query, + onValueChange = onQueryChange, + modifier = modifier.fillMaxWidth(), + singleLine = true, + shape = RoundedCornerShape(12.dp), + placeholder = { Text(stringResource(Res.string.cloud_library_search_label)) }, + leadingIcon = { + Icon( + imageVector = Icons.Rounded.Search, + contentDescription = null, + ) + }, + trailingIcon = { + if (query.isNotEmpty()) { + IconButton(onClick = { onQueryChange("") }) { + Icon( + imageVector = Icons.Rounded.Close, + contentDescription = stringResource(Res.string.compose_search_clear), + ) + } + } + }, + ) +} + private fun LazyListScope.cloudLibrarySkeletonItems() { item(key = "cloud-library-skeleton-toolbar") { CloudLibrarySkeletonToolbar(