From 0679eed1a3439c6a6862e7a721cb171c5f339620 Mon Sep 17 00:00:00 2001 From: Harry Andreolas Date: Sat, 14 Feb 2026 11:32:59 +0200 Subject: [PATCH 1/6] feat: open discover screen upon selecting genre on details --- .../home/navigation/NavigationRouter.kt | 2 +- .../core/commons/util/SerializableExt.kt | 6 ++ .../details/media/DetailsDataFactory.kt | 4 ++ .../kotlin/com/divinelink/core/model/Genre.kt | 3 + .../core/model/details/media/DetailsData.kt | 2 + .../core/navigation/route/DiscoverRoute.kt | 4 +- .../core/navigation/route/Navigation.kt | 6 +- .../core/ui/components/DiscoverFab.kt | 10 +++- .../components/details/genres/GenreLabel.kt | 26 ++++---- .../details/media/ui/DetailsViewModel.kt | 43 +++++++++----- .../media/ui/components/GenresSection.kt | 4 +- .../media/ui/forms/about/AboutFormContent.kt | 11 +++- .../feature/discover/DiscoverUiState.kt | 59 +++++++++++-------- .../feature/discover/DiscoverViewModel.kt | 25 +++++++- .../discover/filters/SelectFilterViewModel.kt | 8 ++- 15 files changed, 151 insertions(+), 62 deletions(-) create mode 100644 core/commons/src/commonMain/kotlin/com/divinelink/core/commons/util/SerializableExt.kt diff --git a/app/src/commonMain/kotlin/com/divinelink/scenepeek/home/navigation/NavigationRouter.kt b/app/src/commonMain/kotlin/com/divinelink/scenepeek/home/navigation/NavigationRouter.kt index 309306d5f..c7fd54347 100644 --- a/app/src/commonMain/kotlin/com/divinelink/scenepeek/home/navigation/NavigationRouter.kt +++ b/app/src/commonMain/kotlin/com/divinelink/scenepeek/home/navigation/NavigationRouter.kt @@ -66,7 +66,7 @@ fun NavController.findNavigation(route: Navigation) { is Navigation.WebViewRoute -> navigateToWebView(route) is Navigation.ActionMenuRoute.Media -> openDefaultActionMenuModal(route) Navigation.JellyseerrRequestsRoute -> navigateToRequests() - Navigation.DiscoverRoute -> navigateToDiscover() + is Navigation.DiscoverRoute -> navigateToDiscover(route) is Navigation.MediaPosterRoute -> navigateToPoster(route) is Navigation.MediaListsRoute -> navigateToMediaLists(route) is Navigation.SeasonRoute -> navigateToSeason(route) diff --git a/core/commons/src/commonMain/kotlin/com/divinelink/core/commons/util/SerializableExt.kt b/core/commons/src/commonMain/kotlin/com/divinelink/core/commons/util/SerializableExt.kt new file mode 100644 index 000000000..5dc5d5df8 --- /dev/null +++ b/core/commons/src/commonMain/kotlin/com/divinelink/core/commons/util/SerializableExt.kt @@ -0,0 +1,6 @@ +package com.divinelink.core.commons.util + +import kotlinx.serialization.json.Json + +inline fun T.encodeToString() = Json.encodeToString(this) +inline fun String.decodeFromString() = Json.decodeFromString(this) diff --git a/core/fixtures/src/commonMain/kotlin/com/divinelink/core/fixtures/details/media/DetailsDataFactory.kt b/core/fixtures/src/commonMain/kotlin/com/divinelink/core/fixtures/details/media/DetailsDataFactory.kt index 28c24f727..a0067a362 100644 --- a/core/fixtures/src/commonMain/kotlin/com/divinelink/core/fixtures/details/media/DetailsDataFactory.kt +++ b/core/fixtures/src/commonMain/kotlin/com/divinelink/core/fixtures/details/media/DetailsDataFactory.kt @@ -7,11 +7,13 @@ import com.divinelink.core.fixtures.model.details.KeywordFactory import com.divinelink.core.fixtures.model.details.MediaDetailsFactory import com.divinelink.core.fixtures.model.media.MediaItemFactory import com.divinelink.core.model.details.media.DetailsData +import com.divinelink.core.model.media.MediaType object DetailsDataFactory { object Empty { fun about() = DetailsData.About( + mediaType = MediaType.UNKNOWN, overview = null, tagline = null, genres = null, @@ -41,6 +43,7 @@ object DetailsDataFactory { object Movie { fun about() = DetailsData.About( + mediaType = MediaType.MOVIE, overview = MediaDetailsFactory.FightClub().overview, tagline = MediaDetailsFactory.FightClub().tagline, genres = MediaDetailsFactory.FightClub().genres, @@ -66,6 +69,7 @@ object DetailsDataFactory { object Tv { fun about() = DetailsData.About( + mediaType = MediaType.TV, overview = MediaDetailsFactory.TheOffice().overview, tagline = MediaDetailsFactory.TheOffice().tagline, genres = MediaDetailsFactory.TheOffice().genres, diff --git a/core/model/src/commonMain/kotlin/com/divinelink/core/model/Genre.kt b/core/model/src/commonMain/kotlin/com/divinelink/core/model/Genre.kt index b39799318..8423d0f01 100644 --- a/core/model/src/commonMain/kotlin/com/divinelink/core/model/Genre.kt +++ b/core/model/src/commonMain/kotlin/com/divinelink/core/model/Genre.kt @@ -1,5 +1,8 @@ package com.divinelink.core.model +import kotlinx.serialization.Serializable + +@Serializable data class Genre( val id: Int, val name: String, diff --git a/core/model/src/commonMain/kotlin/com/divinelink/core/model/details/media/DetailsData.kt b/core/model/src/commonMain/kotlin/com/divinelink/core/model/details/media/DetailsData.kt index 335105bed..c45160681 100644 --- a/core/model/src/commonMain/kotlin/com/divinelink/core/model/details/media/DetailsData.kt +++ b/core/model/src/commonMain/kotlin/com/divinelink/core/model/details/media/DetailsData.kt @@ -7,9 +7,11 @@ import com.divinelink.core.model.details.Person import com.divinelink.core.model.details.Season import com.divinelink.core.model.details.review.Review import com.divinelink.core.model.media.MediaItem +import com.divinelink.core.model.media.MediaType sealed interface DetailsData { data class About( + val mediaType: MediaType, val overview: String?, val tagline: String?, val genres: List?, diff --git a/core/scaffold/src/commonMain/kotlin/com/divinelink/core/navigation/route/DiscoverRoute.kt b/core/scaffold/src/commonMain/kotlin/com/divinelink/core/navigation/route/DiscoverRoute.kt index a9a528b9f..170b90076 100644 --- a/core/scaffold/src/commonMain/kotlin/com/divinelink/core/navigation/route/DiscoverRoute.kt +++ b/core/scaffold/src/commonMain/kotlin/com/divinelink/core/navigation/route/DiscoverRoute.kt @@ -2,6 +2,6 @@ package com.divinelink.core.navigation.route import androidx.navigation.NavController -fun NavController.navigateToDiscover() = navigate( - route = Navigation.DiscoverRoute, +fun NavController.navigateToDiscover(route: Navigation.DiscoverRoute) = navigate( + route = route, ) diff --git a/core/scaffold/src/commonMain/kotlin/com/divinelink/core/navigation/route/Navigation.kt b/core/scaffold/src/commonMain/kotlin/com/divinelink/core/navigation/route/Navigation.kt index 68929740e..2b776a9da 100644 --- a/core/scaffold/src/commonMain/kotlin/com/divinelink/core/navigation/route/Navigation.kt +++ b/core/scaffold/src/commonMain/kotlin/com/divinelink/core/navigation/route/Navigation.kt @@ -148,7 +148,11 @@ sealed interface Navigation { data object JellyseerrRequestsRoute : Navigation @Serializable - data object DiscoverRoute : Navigation + data class DiscoverRoute( + val mediaType: String?, + val encodedGenre: String?, + val encodedKeyword: String?, + ) : Navigation @Serializable data class MediaPosterRoute( diff --git a/core/scaffold/src/commonMain/kotlin/com/divinelink/core/ui/components/DiscoverFab.kt b/core/scaffold/src/commonMain/kotlin/com/divinelink/core/ui/components/DiscoverFab.kt index 0e6b2039d..0c38c3126 100644 --- a/core/scaffold/src/commonMain/kotlin/com/divinelink/core/ui/components/DiscoverFab.kt +++ b/core/scaffold/src/commonMain/kotlin/com/divinelink/core/ui/components/DiscoverFab.kt @@ -23,6 +23,14 @@ fun ScaffoldState.DiscoverFab( icon = Icons.Default.SavedSearch, text = stringResource(UiString.core_ui_discover), expanded = expanded, - onClick = { onNavigate(Navigation.DiscoverRoute) }, + onClick = { + onNavigate( + Navigation.DiscoverRoute( + mediaType = null, + encodedGenre = null, + encodedKeyword = null, + ), + ) + }, ) } diff --git a/core/ui/src/commonMain/kotlin/com/divinelink/core/ui/components/details/genres/GenreLabel.kt b/core/ui/src/commonMain/kotlin/com/divinelink/core/ui/components/details/genres/GenreLabel.kt index 28c76c043..4ae0af024 100644 --- a/core/ui/src/commonMain/kotlin/com/divinelink/core/ui/components/details/genres/GenreLabel.kt +++ b/core/ui/src/commonMain/kotlin/com/divinelink/core/ui/components/details/genres/GenreLabel.kt @@ -1,11 +1,11 @@ package com.divinelink.core.ui.components.details.genres import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.layout.wrapContentSize -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -15,7 +15,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp +import com.divinelink.core.designsystem.theme.dimensions import com.divinelink.core.fixtures.model.GenreFactory import com.divinelink.core.model.Genre import com.divinelink.core.ui.Previews @@ -25,24 +25,28 @@ import com.divinelink.core.ui.composition.PreviewLocalProvider fun GenreLabel( modifier: Modifier = Modifier, genre: Genre, - onGenreClick: (Genre) -> Unit, + onClick: (Genre) -> Unit, ) { Surface( - shape = RoundedCornerShape(4.dp), + shape = MaterialTheme.shapes.small, modifier = modifier .wrapContentSize(Alignment.Center) .wrapContentHeight() - .clip(RoundedCornerShape(4.dp)), -// .clickable { -// onGenreClick(genre) -// }, - border = BorderStroke(width = 1.dp, color = MaterialTheme.colorScheme.onBackground), + .clip(MaterialTheme.shapes.small) + .clickable { onClick(genre) }, + border = BorderStroke( + width = MaterialTheme.dimensions.keyline_1, + color = MaterialTheme.colorScheme.onBackground, + ), ) { Box { Text( modifier = Modifier .align(Alignment.Center) - .padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 8.dp), + .padding( + horizontal = MaterialTheme.dimensions.keyline_16, + vertical = MaterialTheme.dimensions.keyline_8, + ), textAlign = TextAlign.Center, text = genre.name, style = MaterialTheme.typography.bodyMedium, @@ -58,7 +62,7 @@ private fun GenreLabelPreview() { PreviewLocalProvider { GenreLabel( genre = GenreFactory.Movie.documentary, - onGenreClick = {}, + onClick = {}, ) } } diff --git a/feature/details/src/commonMain/kotlin/com/divinelink/feature/details/media/ui/DetailsViewModel.kt b/feature/details/src/commonMain/kotlin/com/divinelink/feature/details/media/ui/DetailsViewModel.kt index 4b299218b..cd19ca671 100644 --- a/feature/details/src/commonMain/kotlin/com/divinelink/feature/details/media/ui/DetailsViewModel.kt +++ b/feature/details/src/commonMain/kotlin/com/divinelink/feature/details/media/ui/DetailsViewModel.kt @@ -181,7 +181,12 @@ class DetailsViewModel( val aboutOrder = MovieTab.About.order val castOrder = MovieTab.Cast.order val updatedForms = viewState.forms.toMutableMap().apply { - this[aboutOrder] = DetailsForm.Content(getAboutDetailsData(data)) + this[aboutOrder] = DetailsForm.Content( + getAboutDetailsData( + mediaType = MediaType.MOVIE, + result = data, + ), + ) this[castOrder] = DetailsForm.Content( DetailsData.Cast( isTv = false, @@ -201,7 +206,12 @@ class DetailsViewModel( val aboutOrder = TvTab.About.order val seasonsTabOrder = TvTab.Seasons.order val updatedForms = viewState.forms.toMutableMap().apply { - this[aboutOrder] = DetailsForm.Content(getAboutDetailsData(data)) + this[aboutOrder] = DetailsForm.Content( + getAboutDetailsData( + mediaType = MediaType.TV, + result = data, + ), + ) this[seasonsTabOrder] = DetailsForm.Content( DetailsData.Seasons((data.mediaDetails as TV).seasons), ) @@ -790,19 +800,22 @@ class DetailsViewModel( } } - private fun getAboutDetailsData(result: MediaDetailsResult.DetailsSuccess): DetailsData.About = - DetailsData.About( - overview = result.mediaDetails.overview, - tagline = result.mediaDetails.tagline, - genres = result.mediaDetails.genres, - creators = when (result.mediaDetails) { - is TV -> (result.mediaDetails as TV).creators - is Movie -> (result.mediaDetails as Movie).creators - }, - information = result.mediaDetails.information, - collection = (result.mediaDetails as? Movie)?.collection, - keywords = result.mediaDetails.keywords, - ) + private fun getAboutDetailsData( + mediaType: MediaType, + result: MediaDetailsResult.DetailsSuccess, + ): DetailsData.About = DetailsData.About( + mediaType = mediaType, + overview = result.mediaDetails.overview, + tagline = result.mediaDetails.tagline, + genres = result.mediaDetails.genres, + creators = when (result.mediaDetails) { + is TV -> (result.mediaDetails as TV).creators + is Movie -> (result.mediaDetails as Movie).creators + }, + information = result.mediaDetails.information, + collection = (result.mediaDetails as? Movie)?.collection, + keywords = result.mediaDetails.keywords, + ) /** * @param overrideSeasonStatus If true, the season status will be set to UNKNOWN. diff --git a/feature/details/src/commonMain/kotlin/com/divinelink/feature/details/media/ui/components/GenresSection.kt b/feature/details/src/commonMain/kotlin/com/divinelink/feature/details/media/ui/components/GenresSection.kt index 370956847..43a472768 100644 --- a/feature/details/src/commonMain/kotlin/com/divinelink/feature/details/media/ui/components/GenresSection.kt +++ b/feature/details/src/commonMain/kotlin/com/divinelink/feature/details/media/ui/components/GenresSection.kt @@ -19,7 +19,7 @@ import org.jetbrains.compose.resources.stringResource fun GenresSection( modifier: Modifier = Modifier, genres: List, - onGenreClick: (Genre) -> Unit, + onClick: (Genre) -> Unit, ) { Column(modifier = modifier) { Text( @@ -34,7 +34,7 @@ fun GenresSection( genres.forEach { genre -> GenreLabel( genre = genre, - onGenreClick = onGenreClick, + onClick = onClick, ) } } diff --git a/feature/details/src/commonMain/kotlin/com/divinelink/feature/details/media/ui/forms/about/AboutFormContent.kt b/feature/details/src/commonMain/kotlin/com/divinelink/feature/details/media/ui/forms/about/AboutFormContent.kt index c77def78e..aa68f3018 100644 --- a/feature/details/src/commonMain/kotlin/com/divinelink/feature/details/media/ui/forms/about/AboutFormContent.kt +++ b/feature/details/src/commonMain/kotlin/com/divinelink/feature/details/media/ui/forms/about/AboutFormContent.kt @@ -12,6 +12,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.font.FontStyle +import com.divinelink.core.commons.util.encodeToString import com.divinelink.core.designsystem.component.ScenePeekLazyColumn import com.divinelink.core.designsystem.theme.LocalBottomNavigationPadding import com.divinelink.core.designsystem.theme.dimensions @@ -69,7 +70,15 @@ fun AboutFormContent( GenresSection( modifier = Modifier.padding(horizontal = MaterialTheme.dimensions.keyline_16), genres = genres, - onGenreClick = {}, + onClick = { + onNavigate( + Navigation.DiscoverRoute( + mediaType = aboutData.mediaType.value, + encodedGenre = it.encodeToString(), + encodedKeyword = null, + ), + ) + }, ) } } diff --git a/feature/discover/src/commonMain/kotlin/com/divinelink/feature/discover/DiscoverUiState.kt b/feature/discover/src/commonMain/kotlin/com/divinelink/feature/discover/DiscoverUiState.kt index 63ea90ccf..10d140fed 100644 --- a/feature/discover/src/commonMain/kotlin/com/divinelink/feature/discover/DiscoverUiState.kt +++ b/feature/discover/src/commonMain/kotlin/com/divinelink/feature/discover/DiscoverUiState.kt @@ -5,6 +5,7 @@ import com.divinelink.core.model.media.MediaItem import com.divinelink.core.model.media.MediaType import com.divinelink.core.model.sort.SortOption import com.divinelink.core.model.tab.MediaTab +import com.divinelink.core.navigation.route.Navigation data class DiscoverUiState( val selectedTabIndex: Int, @@ -17,31 +18,39 @@ data class DiscoverUiState( val loadingMap: Map, ) { companion object { - val initial = DiscoverUiState( - selectedTabIndex = MediaTab.Movie.order, - tabs = MediaTab.entries, - pages = mapOf( - MediaType.MOVIE to 1, - MediaType.TV to 1, - ), - forms = mapOf( - MediaType.MOVIE to DiscoverForm.Initial, - MediaType.TV to DiscoverForm.Initial, - ), - canFetchMore = mapOf( - MediaType.MOVIE to true, - MediaType.TV to true, - ), - filters = mapOf( - MediaType.MOVIE to MediaTypeFilters.initial, - MediaType.TV to MediaTypeFilters.initial, - ), - sortOption = emptyMap(), - loadingMap = mapOf( - MediaType.MOVIE to false, - MediaType.TV to false, - ), - ) + fun initial(route: Navigation.DiscoverRoute): DiscoverUiState { + val mediaType = MediaType.from(route.mediaType) + + return DiscoverUiState( + selectedTabIndex = when (mediaType) { + MediaType.MOVIE -> MediaTab.Movie.order + MediaType.TV -> MediaTab.TV.order + else -> MediaTab.Movie.order + }, + tabs = MediaTab.entries, + pages = mapOf( + MediaType.MOVIE to 1, + MediaType.TV to 1, + ), + forms = mapOf( + MediaType.MOVIE to DiscoverForm.Initial, + MediaType.TV to DiscoverForm.Initial, + ), + canFetchMore = mapOf( + MediaType.MOVIE to true, + MediaType.TV to true, + ), + filters = mapOf( + MediaType.MOVIE to MediaTypeFilters.initial, + MediaType.TV to MediaTypeFilters.initial, + ), + sortOption = emptyMap(), + loadingMap = mapOf( + MediaType.MOVIE to false, + MediaType.TV to false, + ), + ) + } } val selectedTab = tabs[selectedTabIndex] diff --git a/feature/discover/src/commonMain/kotlin/com/divinelink/feature/discover/DiscoverViewModel.kt b/feature/discover/src/commonMain/kotlin/com/divinelink/feature/discover/DiscoverViewModel.kt index 138043165..56b9a7205 100644 --- a/feature/discover/src/commonMain/kotlin/com/divinelink/feature/discover/DiscoverViewModel.kt +++ b/feature/discover/src/commonMain/kotlin/com/divinelink/feature/discover/DiscoverViewModel.kt @@ -1,10 +1,13 @@ package com.divinelink.feature.discover +import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.divinelink.core.commons.util.decodeFromString import com.divinelink.core.data.FilterRepository import com.divinelink.core.data.preferences.PreferencesRepository import com.divinelink.core.domain.DiscoverMediaUseCase +import com.divinelink.core.model.Genre import com.divinelink.core.model.discover.DiscoverParameters import com.divinelink.core.model.discover.MediaTypeFilters import com.divinelink.core.model.exception.AppException @@ -12,6 +15,7 @@ import com.divinelink.core.model.media.MediaType import com.divinelink.core.model.sort.SortOption import com.divinelink.core.model.ui.ViewableSection import com.divinelink.core.model.user.data.UserDataResponse +import com.divinelink.core.navigation.route.Navigation import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.distinctUntilChanged @@ -25,16 +29,35 @@ class DiscoverViewModel( private val filterRepository: FilterRepository, private val discoverUseCase: DiscoverMediaUseCase, preferencesRepository: PreferencesRepository, + savedStateHandle: SavedStateHandle, ) : ViewModel() { + private val route = Navigation.DiscoverRoute( + mediaType = savedStateHandle.get("mediaType"), + encodedGenre = savedStateHandle.get("encodedGenre"), + encodedKeyword = savedStateHandle.get("encodedKeyword"), + ) + private val _uiState: MutableStateFlow = MutableStateFlow( - DiscoverUiState.initial, + DiscoverUiState.initial(route), ) val uiState: StateFlow = _uiState init { filterRepository.clear(_uiState.value.selectedMedia) + val mediaType = MediaType.from(route.mediaType) + val genre = route.encodedGenre?.decodeFromString() + + filterRepository.updateSelectedGenres( + mediaType = mediaType, + genres = if (genre != null) { + listOf(genre) + } else { + emptyList() + }, + ) + preferencesRepository .uiPreferences .mapNotNull { uiPreferences -> diff --git a/feature/discover/src/commonMain/kotlin/com/divinelink/feature/discover/filters/SelectFilterViewModel.kt b/feature/discover/src/commonMain/kotlin/com/divinelink/feature/discover/filters/SelectFilterViewModel.kt index b1c7e3b7d..67627e417 100644 --- a/feature/discover/src/commonMain/kotlin/com/divinelink/feature/discover/filters/SelectFilterViewModel.kt +++ b/feature/discover/src/commonMain/kotlin/com/divinelink/feature/discover/filters/SelectFilterViewModel.kt @@ -173,22 +173,26 @@ class SelectFilterViewModel( ) } is Resource.Loading -> _uiState.update { uiState -> + val selected = (uiState.filterType as? FilterType.Searchable.Genres)?.selectedOptions + uiState.copy( loading = false, filterType = FilterType.Searchable.Genres( options = result.data ?: emptyList(), - selectedOptions = emptyList(), + selectedOptions = selected ?: emptyList(), query = null, ), error = null, ) } is Resource.Success -> _uiState.update { uiState -> + val selected = (uiState.filterType as? FilterType.Searchable.Genres)?.selectedOptions + uiState.copy( loading = false, filterType = FilterType.Searchable.Genres( options = result.data, - selectedOptions = emptyList(), + selectedOptions = selected ?: emptyList(), query = null, ), error = null, From 81c58f4b59e6202173c8c6b8612be17d443b398b Mon Sep 17 00:00:00 2001 From: Harry Andreolas Date: Sat, 14 Feb 2026 17:54:44 +0200 Subject: [PATCH 2/6] feat: open discover and filter upon selecting keyword from details --- .../divinelink/core/data/FilterRepository.kt | 25 ++++++++++ .../core/domain/DiscoverMediaUseCase.kt | 3 ++ .../divinelink/core/model/details/Keyword.kt | 3 ++ .../core/model/discover/DiscoverFilter.kt | 2 + .../core/model/discover/MediaTypeFilters.kt | 4 ++ .../core/network/media/util/BuildUrl.kt | 3 ++ .../core/navigation/route/Navigation.kt | 4 +- .../media/ui/forms/about/AboutFormContent.kt | 30 +++++++----- .../feature/discover/DiscoverViewModel.kt | 47 ++++++++++++++----- 9 files changed, 97 insertions(+), 24 deletions(-) diff --git a/core/data/src/commonMain/kotlin/com/divinelink/core/data/FilterRepository.kt b/core/data/src/commonMain/kotlin/com/divinelink/core/data/FilterRepository.kt index 259874d84..7c1044132 100644 --- a/core/data/src/commonMain/kotlin/com/divinelink/core/data/FilterRepository.kt +++ b/core/data/src/commonMain/kotlin/com/divinelink/core/data/FilterRepository.kt @@ -1,6 +1,7 @@ package com.divinelink.core.data import com.divinelink.core.model.Genre +import com.divinelink.core.model.details.Keyword import com.divinelink.core.model.discover.DiscoverFilter import com.divinelink.core.model.locale.Country import com.divinelink.core.model.locale.Language @@ -59,6 +60,14 @@ class FilterRepository { ) val year: StateFlow> = _year.asStateFlow() + private val _keywords = MutableStateFlow>>( + mapOf( + MediaType.MOVIE to emptyList(), + MediaType.TV to emptyList(), + ), + ) + val keywords: StateFlow>> = _keywords.asStateFlow() + fun updateSelectedGenres( mediaType: MediaType, genres: List, @@ -101,6 +110,21 @@ class FilterRepository { _year.value += mediaType to year } + fun updateKeyword( + mediaType: MediaType, + keyword: Keyword, + ) { + val currentKeywords = _keywords.value[mediaType] ?: emptyList() + + val keywords = if (keyword in currentKeywords) { + currentKeywords - keyword + } else { + currentKeywords + keyword + } + + _keywords.value += mediaType to keywords + } + fun clearRatings(mediaType: MediaType) { _voteAverage.value += mediaType to null _minimumVotes.value += mediaType to null @@ -108,6 +132,7 @@ class FilterRepository { fun clear(mediaType: MediaType) { _selectedGenres.value += mediaType to emptyList() + _keywords.value += mediaType to emptyList() _selectedLanguage.value += mediaType to null _selectedCountry.value += mediaType to null _voteAverage.value += mediaType to null diff --git a/core/domain/src/commonMain/kotlin/com/divinelink/core/domain/DiscoverMediaUseCase.kt b/core/domain/src/commonMain/kotlin/com/divinelink/core/domain/DiscoverMediaUseCase.kt index a0a1c7bfe..3dbdb6d1e 100644 --- a/core/domain/src/commonMain/kotlin/com/divinelink/core/domain/DiscoverMediaUseCase.kt +++ b/core/domain/src/commonMain/kotlin/com/divinelink/core/domain/DiscoverMediaUseCase.kt @@ -26,6 +26,9 @@ class DiscoverMediaUseCase( voteAverage?.let { add(voteAverage) } year?.let { add(year) } add(DiscoverFilter.MinimumVotes(votes ?: 10)) + if (keywords.isNotEmpty()) { + add(DiscoverFilter.Keywords(keywords.map { it.id })) + } } }.mapNotNull { it } diff --git a/core/model/src/commonMain/kotlin/com/divinelink/core/model/details/Keyword.kt b/core/model/src/commonMain/kotlin/com/divinelink/core/model/details/Keyword.kt index 5ae0e0be4..24163e6a8 100644 --- a/core/model/src/commonMain/kotlin/com/divinelink/core/model/details/Keyword.kt +++ b/core/model/src/commonMain/kotlin/com/divinelink/core/model/details/Keyword.kt @@ -1,5 +1,8 @@ package com.divinelink.core.model.details +import kotlinx.serialization.Serializable + +@Serializable data class Keyword( val id: Long, val name: String, diff --git a/core/model/src/commonMain/kotlin/com/divinelink/core/model/discover/DiscoverFilter.kt b/core/model/src/commonMain/kotlin/com/divinelink/core/model/discover/DiscoverFilter.kt index 18176d783..babe620d0 100644 --- a/core/model/src/commonMain/kotlin/com/divinelink/core/model/discover/DiscoverFilter.kt +++ b/core/model/src/commonMain/kotlin/com/divinelink/core/model/discover/DiscoverFilter.kt @@ -11,6 +11,8 @@ sealed interface DiscoverFilter { data class MinimumVotes(val votes: Int) : DiscoverFilter + data class Keywords(val ids: List) : DiscoverFilter + sealed interface Year : DiscoverFilter { data class Single(val year: Int) : Year diff --git a/core/model/src/commonMain/kotlin/com/divinelink/core/model/discover/MediaTypeFilters.kt b/core/model/src/commonMain/kotlin/com/divinelink/core/model/discover/MediaTypeFilters.kt index 59683b03e..308b571dc 100644 --- a/core/model/src/commonMain/kotlin/com/divinelink/core/model/discover/MediaTypeFilters.kt +++ b/core/model/src/commonMain/kotlin/com/divinelink/core/model/discover/MediaTypeFilters.kt @@ -1,6 +1,7 @@ package com.divinelink.core.model.discover import com.divinelink.core.model.Genre +import com.divinelink.core.model.details.Keyword import com.divinelink.core.model.locale.Country import com.divinelink.core.model.locale.Language @@ -11,10 +12,12 @@ data class MediaTypeFilters( val year: DiscoverFilter.Year?, val voteAverage: DiscoverFilter.VoteAverage?, val votes: Int?, + val keywords: List, ) { companion object { val initial = MediaTypeFilters( genres = emptyList(), + keywords = emptyList(), year = null, language = null, country = null, @@ -25,6 +28,7 @@ data class MediaTypeFilters( val hasSelectedFilters get() = genres.isNotEmpty() || + keywords.isNotEmpty() || language != null || country != null || voteAverage != null || diff --git a/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/util/BuildUrl.kt b/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/util/BuildUrl.kt index 86a224b55..6c54cb0eb 100644 --- a/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/util/BuildUrl.kt +++ b/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/util/BuildUrl.kt @@ -92,6 +92,9 @@ fun buildDiscoverUrl( append("primary_release_date.gte", filter.startDateTime) append("primary_release_date.lte", filter.endDateTime) } + is DiscoverFilter.Keywords -> if (filter.ids.isNotEmpty()) { + append("with_keywords", filter.ids.joinToString(",")) + } } } } diff --git a/core/scaffold/src/commonMain/kotlin/com/divinelink/core/navigation/route/Navigation.kt b/core/scaffold/src/commonMain/kotlin/com/divinelink/core/navigation/route/Navigation.kt index 2b776a9da..064519d3c 100644 --- a/core/scaffold/src/commonMain/kotlin/com/divinelink/core/navigation/route/Navigation.kt +++ b/core/scaffold/src/commonMain/kotlin/com/divinelink/core/navigation/route/Navigation.kt @@ -150,8 +150,8 @@ sealed interface Navigation { @Serializable data class DiscoverRoute( val mediaType: String?, - val encodedGenre: String?, - val encodedKeyword: String?, + val encodedGenre: String? = null, + val encodedKeyword: String? = null, ) : Navigation @Serializable diff --git a/feature/details/src/commonMain/kotlin/com/divinelink/feature/details/media/ui/forms/about/AboutFormContent.kt b/feature/details/src/commonMain/kotlin/com/divinelink/feature/details/media/ui/forms/about/AboutFormContent.kt index aa68f3018..aef551420 100644 --- a/feature/details/src/commonMain/kotlin/com/divinelink/feature/details/media/ui/forms/about/AboutFormContent.kt +++ b/feature/details/src/commonMain/kotlin/com/divinelink/feature/details/media/ui/forms/about/AboutFormContent.kt @@ -75,7 +75,6 @@ fun AboutFormContent( Navigation.DiscoverRoute( mediaType = aboutData.mediaType.value, encodedGenre = it.encodeToString(), - encodedKeyword = null, ), ) }, @@ -134,16 +133,25 @@ fun AboutFormContent( } aboutData.keywords?.let { keywords -> - item { - HorizontalDivider( - modifier = Modifier.padding(horizontal = MaterialTheme.dimensions.keyline_16), - ) - } - item { - KeywordsSection( - keywords = keywords, - onClick = {}, - ) + if (keywords.isNotEmpty()) { + item { + HorizontalDivider( + modifier = Modifier.padding(horizontal = MaterialTheme.dimensions.keyline_16), + ) + } + item { + KeywordsSection( + keywords = keywords, + onClick = { + onNavigate( + Navigation.DiscoverRoute( + mediaType = aboutData.mediaType.value, + encodedKeyword = it.encodeToString(), + ), + ) + }, + ) + } } } diff --git a/feature/discover/src/commonMain/kotlin/com/divinelink/feature/discover/DiscoverViewModel.kt b/feature/discover/src/commonMain/kotlin/com/divinelink/feature/discover/DiscoverViewModel.kt index 56b9a7205..3cc47c0ca 100644 --- a/feature/discover/src/commonMain/kotlin/com/divinelink/feature/discover/DiscoverViewModel.kt +++ b/feature/discover/src/commonMain/kotlin/com/divinelink/feature/discover/DiscoverViewModel.kt @@ -8,6 +8,7 @@ import com.divinelink.core.data.FilterRepository import com.divinelink.core.data.preferences.PreferencesRepository import com.divinelink.core.domain.DiscoverMediaUseCase import com.divinelink.core.model.Genre +import com.divinelink.core.model.details.Keyword import com.divinelink.core.model.discover.DiscoverParameters import com.divinelink.core.model.discover.MediaTypeFilters import com.divinelink.core.model.exception.AppException @@ -24,6 +25,7 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch class DiscoverViewModel( private val filterRepository: FilterRepository, @@ -44,19 +46,27 @@ class DiscoverViewModel( val uiState: StateFlow = _uiState init { - filterRepository.clear(_uiState.value.selectedMedia) + viewModelScope.launch { + filterRepository.clear(_uiState.value.selectedMedia) - val mediaType = MediaType.from(route.mediaType) - val genre = route.encodedGenre?.decodeFromString() + val mediaType = MediaType.from(route.mediaType) + val genre = route.encodedGenre?.decodeFromString() + val keyword = route.encodedKeyword?.decodeFromString() - filterRepository.updateSelectedGenres( - mediaType = mediaType, - genres = if (genre != null) { - listOf(genre) - } else { - emptyList() - }, - ) + genre?.let { genre -> + filterRepository.updateSelectedGenres( + mediaType = mediaType, + genres = listOf(genre), + ) + } + + keyword?.let { keyword -> + filterRepository.updateKeyword( + mediaType = mediaType, + keyword = keyword, + ) + } + } preferencesRepository .uiPreferences @@ -166,6 +176,21 @@ class DiscoverViewModel( } } .launchIn(viewModelScope) + + filterRepository + .keywords + .map { it[uiState.value.selectedMedia] ?: emptyList() } + .onEach { keywords -> + _uiState.update { uiState -> + uiState.copy( + filters = uiState.filters.updateFilters( + mediaType = uiState.selectedTab.mediaType, + update = { it.copy(keywords = keywords) }, + ), + ) + } + } + .launchIn(viewModelScope) } fun onAction(action: DiscoverAction) { From 5d4bac5c8abed71ad913860fe6c4a6fdf280d6f2 Mon Sep 17 00:00:00 2001 From: Harry Andreolas Date: Sun, 15 Feb 2026 14:09:18 +0200 Subject: [PATCH 3/6] feat: implement search keyword api call --- .../data/media/repository/MediaRepository.kt | 5 +++++ .../media/repository/ProdMediaRepository.kt | 14 ++++++++++++++ .../mapper/details/KeywordsResponseMapper.kt | 2 +- .../media/model/search/SearchKeywordResponse.kt | 13 +++++++++++++ .../core/network/media/service/MediaService.kt | 5 +++++ .../network/media/service/ProdMediaService.kt | 8 ++++++++ .../core/network/media/util/BuildUrl.kt | 17 ++++++++++++++++- 7 files changed, 62 insertions(+), 2 deletions(-) create mode 100644 core/network/src/commonMain/kotlin/com/divinelink/core/network/media/model/search/SearchKeywordResponse.kt diff --git a/core/data/src/commonMain/kotlin/com/divinelink/core/data/media/repository/MediaRepository.kt b/core/data/src/commonMain/kotlin/com/divinelink/core/data/media/repository/MediaRepository.kt index 047498fbb..1ea22fb1c 100644 --- a/core/data/src/commonMain/kotlin/com/divinelink/core/data/media/repository/MediaRepository.kt +++ b/core/data/src/commonMain/kotlin/com/divinelink/core/data/media/repository/MediaRepository.kt @@ -1,8 +1,11 @@ +@file:Suppress("TooManyFunctions") + package com.divinelink.core.data.media.repository import com.divinelink.core.model.Genre import com.divinelink.core.model.PaginationData import com.divinelink.core.model.details.Episode +import com.divinelink.core.model.details.Keyword import com.divinelink.core.model.details.Season import com.divinelink.core.model.details.SeasonDetails import com.divinelink.core.model.discover.DiscoverFilter @@ -54,6 +57,8 @@ interface MediaRepository { fun fetchFavorites(mediaType: MediaType): Flow>> + suspend fun fetchSearchKeywords(request: SearchRequestApi): Result> + /** * Request movies through a search query. Uses pagination. * Uses [Flow] in order to observe changes to our movies list. diff --git a/core/data/src/commonMain/kotlin/com/divinelink/core/data/media/repository/ProdMediaRepository.kt b/core/data/src/commonMain/kotlin/com/divinelink/core/data/media/repository/ProdMediaRepository.kt index 5d451f923..48464d472 100644 --- a/core/data/src/commonMain/kotlin/com/divinelink/core/data/media/repository/ProdMediaRepository.kt +++ b/core/data/src/commonMain/kotlin/com/divinelink/core/data/media/repository/ProdMediaRepository.kt @@ -8,6 +8,7 @@ import com.divinelink.core.database.person.PersonDao import com.divinelink.core.model.Genre import com.divinelink.core.model.PaginationData import com.divinelink.core.model.details.Episode +import com.divinelink.core.model.details.Keyword import com.divinelink.core.model.details.Season import com.divinelink.core.model.details.SeasonDetails import com.divinelink.core.model.discover.DiscoverFilter @@ -395,4 +396,17 @@ class ProdMediaRepository( override fun clearAllEpisodeRatings(): Result = runCatching { dao.clearAllEpisodeRatings() } + + override suspend fun fetchSearchKeywords( + request: SearchRequestApi, + ): Result> = remote + .searchKeywords(request) + .map { response -> + PaginationData( + page = response.page, + totalPages = response.totalPages, + totalResults = response.totalResults, + list = response.results.map { it.map() }, + ) + } } diff --git a/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/mapper/details/KeywordsResponseMapper.kt b/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/mapper/details/KeywordsResponseMapper.kt index b7348a296..877e9a775 100644 --- a/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/mapper/details/KeywordsResponseMapper.kt +++ b/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/mapper/details/KeywordsResponseMapper.kt @@ -8,7 +8,7 @@ fun KeywordsResponse?.map() = this ?.keywords ?.map { it.map() } -private fun KeywordResponse.map() = Keyword( +fun KeywordResponse.map() = Keyword( id = id, name = name, ) diff --git a/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/model/search/SearchKeywordResponse.kt b/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/model/search/SearchKeywordResponse.kt new file mode 100644 index 000000000..f696e22be --- /dev/null +++ b/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/model/search/SearchKeywordResponse.kt @@ -0,0 +1,13 @@ +package com.divinelink.core.network.media.model.search + +import com.divinelink.core.network.media.model.details.KeywordResponse +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class SearchKeywordResponse( + val page: Int, + val results: List, + @SerialName("total_pages") val totalPages: Int, + @SerialName("total_results") val totalResults: Int, +) diff --git a/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/service/MediaService.kt b/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/service/MediaService.kt index 57dffaddb..82d41b6c0 100644 --- a/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/service/MediaService.kt +++ b/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/service/MediaService.kt @@ -1,3 +1,5 @@ +@file:Suppress("TooManyFunctions") + package com.divinelink.core.network.media.service import com.divinelink.core.model.discover.DiscoverFilter @@ -18,6 +20,7 @@ import com.divinelink.core.network.media.model.find.FindByIdResponseApi import com.divinelink.core.network.media.model.movie.MoviesResponseApi import com.divinelink.core.network.media.model.rating.AddRatingRequestApi import com.divinelink.core.network.media.model.rating.DeleteRatingRequestApi +import com.divinelink.core.network.media.model.search.SearchKeywordResponse import com.divinelink.core.network.media.model.search.movie.SearchRequestApi import com.divinelink.core.network.media.model.search.multi.MultiSearchRequestApi import com.divinelink.core.network.media.model.search.multi.MultiSearchResponseApi @@ -86,4 +89,6 @@ interface MediaService { ): Result suspend fun fetchCollectionDetails(id: Int): Result + + suspend fun searchKeywords(request: SearchRequestApi): Result } diff --git a/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/service/ProdMediaService.kt b/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/service/ProdMediaService.kt index fc3eb4a56..7dab47b58 100644 --- a/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/service/ProdMediaService.kt +++ b/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/service/ProdMediaService.kt @@ -23,6 +23,7 @@ import com.divinelink.core.network.media.model.movie.MoviesResponseApi import com.divinelink.core.network.media.model.rating.AddRatingRequestApi import com.divinelink.core.network.media.model.rating.AddRatingRequestBodyApi import com.divinelink.core.network.media.model.rating.DeleteRatingRequestApi +import com.divinelink.core.network.media.model.search.SearchKeywordResponse import com.divinelink.core.network.media.model.search.movie.SearchRequestApi import com.divinelink.core.network.media.model.search.multi.MultiSearchRequestApi import com.divinelink.core.network.media.model.search.multi.MultiSearchResponseApi @@ -35,6 +36,7 @@ import com.divinelink.core.network.media.util.buildFetchDetailsUrl import com.divinelink.core.network.media.util.buildFetchMediaListUrl import com.divinelink.core.network.media.util.buildFindByIdUrl import com.divinelink.core.network.media.util.buildGenreUrl +import com.divinelink.core.network.media.util.buildSearchKeywordUrl import com.divinelink.core.network.media.util.buildSeasonDetailsUrl import com.divinelink.core.network.runCatchingWithNetworkRetry import kotlinx.coroutines.flow.Flow @@ -278,4 +280,10 @@ class ProdMediaService( runCatching { restClient.get(url = buildCollectionsUrl(id = id)) } + + override suspend fun searchKeywords( + request: SearchRequestApi, + ): Result = runCatching { + restClient.get(url = buildSearchKeywordUrl(request = request)) + } } diff --git a/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/util/BuildUrl.kt b/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/util/BuildUrl.kt index 6c54cb0eb..77bfab29e 100644 --- a/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/util/BuildUrl.kt +++ b/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/util/BuildUrl.kt @@ -5,6 +5,7 @@ import com.divinelink.core.model.home.MediaListRequest import com.divinelink.core.model.media.MediaType import com.divinelink.core.model.sort.SortOption import com.divinelink.core.network.Routes +import com.divinelink.core.network.media.model.search.movie.SearchRequestApi import io.ktor.http.URLProtocol import io.ktor.http.buildUrl import io.ktor.http.encodedPath @@ -93,7 +94,7 @@ fun buildDiscoverUrl( append("primary_release_date.lte", filter.endDateTime) } is DiscoverFilter.Keywords -> if (filter.ids.isNotEmpty()) { - append("with_keywords", filter.ids.joinToString(",")) + append("with_keywords", filter.ids.joinToString("|")) } } } @@ -159,3 +160,17 @@ fun buildCollectionsUrl(id: Int): String = buildUrl { append("language", "en") } }.toString() + +fun buildSearchKeywordUrl( + request: SearchRequestApi, +): String = buildUrl { + protocol = URLProtocol.HTTPS + host = Routes.TMDb.HOST + encodedPath = Routes.TMDb.V3 + "/search/keyword" + + parameters.apply { + append("language", "en") + append("query", request.query) + append("page", request.page.toString()) + } +}.toString() From 197cb0945a1116d9f1800fab64cd1a87e403b56d Mon Sep 17 00:00:00 2001 From: Harry Andreolas Date: Sun, 15 Feb 2026 14:12:57 +0200 Subject: [PATCH 4/6] feat: add search keywords and filter modal content --- .../divinelink/core/data/FilterRepository.kt | 4 + .../composeResources/values/strings.xml | 2 + .../feature/discover/FilterModal.kt | 1 + .../divinelink/feature/discover/FilterType.kt | 13 ++ .../discover/chips/DiscoverFilterChip.kt | 15 +- .../discover/filters/SelectFilterAction.kt | 4 + .../filters/SelectFilterModalBottomSheet.kt | 8 +- .../discover/filters/SelectFilterUiState.kt | 6 + .../discover/filters/SelectFilterViewModel.kt | 116 ++++++++++--- .../filters/keyword/KeywordsFiltersContent.kt | 155 ++++++++++++++++++ .../feature/discover/ui/DiscoverContent.kt | 19 ++- 11 files changed, 311 insertions(+), 32 deletions(-) create mode 100644 feature/discover/src/commonMain/kotlin/com/divinelink/feature/discover/filters/keyword/KeywordsFiltersContent.kt diff --git a/core/data/src/commonMain/kotlin/com/divinelink/core/data/FilterRepository.kt b/core/data/src/commonMain/kotlin/com/divinelink/core/data/FilterRepository.kt index 7c1044132..271b2a361 100644 --- a/core/data/src/commonMain/kotlin/com/divinelink/core/data/FilterRepository.kt +++ b/core/data/src/commonMain/kotlin/com/divinelink/core/data/FilterRepository.kt @@ -125,6 +125,10 @@ class FilterRepository { _keywords.value += mediaType to keywords } + fun clearKeywords(mediaType: MediaType) { + _keywords.value += mediaType to emptyList() + } + fun clearRatings(mediaType: MediaType) { _voteAverage.value += mediaType to null _minimumVotes.value += mediaType to null diff --git a/core/ui/src/commonMain/composeResources/values/strings.xml b/core/ui/src/commonMain/composeResources/values/strings.xml index 2b2c54f65..4d6754cfd 100644 --- a/core/ui/src/commonMain/composeResources/values/strings.xml +++ b/core/ui/src/commonMain/composeResources/values/strings.xml @@ -202,6 +202,8 @@ No plot available for %1$s at this time. + Search keywords that describe the plot, general theme or mood. + Navigate Up Button Movie image diff --git a/feature/discover/src/commonMain/kotlin/com/divinelink/feature/discover/FilterModal.kt b/feature/discover/src/commonMain/kotlin/com/divinelink/feature/discover/FilterModal.kt index 89b1372c2..3d8fa82fc 100644 --- a/feature/discover/src/commonMain/kotlin/com/divinelink/feature/discover/FilterModal.kt +++ b/feature/discover/src/commonMain/kotlin/com/divinelink/feature/discover/FilterModal.kt @@ -6,4 +6,5 @@ sealed interface FilterModal { data object Language : FilterModal data object Country : FilterModal data object VoteAverage : FilterModal + data object Keywords : FilterModal } diff --git a/feature/discover/src/commonMain/kotlin/com/divinelink/feature/discover/FilterType.kt b/feature/discover/src/commonMain/kotlin/com/divinelink/feature/discover/FilterType.kt index b276e0e47..f9e176602 100644 --- a/feature/discover/src/commonMain/kotlin/com/divinelink/feature/discover/FilterType.kt +++ b/feature/discover/src/commonMain/kotlin/com/divinelink/feature/discover/FilterType.kt @@ -1,6 +1,7 @@ package com.divinelink.feature.discover import com.divinelink.core.model.Genre +import com.divinelink.core.model.details.Keyword import com.divinelink.core.model.discover.YearType import com.divinelink.core.model.locale.Country import com.divinelink.core.model.locale.Language @@ -47,6 +48,18 @@ sealed interface FilterType { } } + data class Keywords( + override val options: List, + override val selectedOptions: List, + override val query: String?, + val loading: Boolean, + ) : Searchable { + override val visibleOptions: List + get() = selectedOptions + .plus(options) + .distinctBy { it.id } + } + data class VoteAverage( val greaterThan: Int, val lessThan: Int, diff --git a/feature/discover/src/commonMain/kotlin/com/divinelink/feature/discover/chips/DiscoverFilterChip.kt b/feature/discover/src/commonMain/kotlin/com/divinelink/feature/discover/chips/DiscoverFilterChip.kt index d90e0306a..a39a5d5c0 100644 --- a/feature/discover/src/commonMain/kotlin/com/divinelink/feature/discover/chips/DiscoverFilterChip.kt +++ b/feature/discover/src/commonMain/kotlin/com/divinelink/feature/discover/chips/DiscoverFilterChip.kt @@ -8,37 +8,38 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import com.divinelink.core.model.Genre import com.divinelink.core.model.discover.DiscoverFilter import com.divinelink.core.model.locale.Country import com.divinelink.core.model.locale.Language import com.divinelink.core.ui.UiString import com.divinelink.core.ui.resources.core_ui_country -import com.divinelink.core.ui.resources.core_ui_genres import com.divinelink.core.ui.resources.core_ui_language import com.divinelink.core.ui.resources.core_ui_rating import com.divinelink.core.ui.resources.core_ui_rating_selected import com.divinelink.core.ui.resources.core_ui_year import com.divinelink.core.ui.resources.core_ui_year_range import com.divinelink.core.ui.resources.core_ui_year_single +import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.stringResource object DiscoverFilterChip { @Composable - fun Genre( + fun MultiSelect( modifier: Modifier, - filters: List, + filters: List, + title: StringResource, + name: String?, onClick: () -> Unit, ) { Chip( modifier = modifier, selected = filters.isNotEmpty(), label = when { - filters.isEmpty() -> stringResource(UiString.core_ui_genres) - filters.size == 1 -> filters.first().name + filters.isEmpty() -> stringResource(title) + filters.size == 1 -> name ?: "" else -> buildString { - append(filters.first().name) + append(name) append("+") append(filters.size - 1) } diff --git a/feature/discover/src/commonMain/kotlin/com/divinelink/feature/discover/filters/SelectFilterAction.kt b/feature/discover/src/commonMain/kotlin/com/divinelink/feature/discover/filters/SelectFilterAction.kt index 41b69e33f..90134e3cf 100644 --- a/feature/discover/src/commonMain/kotlin/com/divinelink/feature/discover/filters/SelectFilterAction.kt +++ b/feature/discover/src/commonMain/kotlin/com/divinelink/feature/discover/filters/SelectFilterAction.kt @@ -2,6 +2,7 @@ package com.divinelink.feature.discover.filters import com.divinelink.core.model.Decade import com.divinelink.core.model.Genre +import com.divinelink.core.model.details.Keyword import com.divinelink.core.model.discover.DiscoverFilter import com.divinelink.core.model.discover.YearType import com.divinelink.core.model.locale.Country @@ -10,6 +11,7 @@ import com.divinelink.core.model.locale.Language sealed interface SelectFilterAction { data object ClearGenres : SelectFilterAction data object ResetRatingFilters : SelectFilterAction + data object ClearKeywords : SelectFilterAction data object Retry : SelectFilterAction data class SelectGenre(val genre: Genre) : SelectFilterAction data class SelectLanguage(val language: Language) : SelectFilterAction @@ -22,4 +24,6 @@ sealed interface SelectFilterAction { data class UpdateStartYear(val startYear: Int) : SelectFilterAction data class UpdateEndYear(val endYear: Int) : SelectFilterAction data class OnSelectDecade(val decade: Decade) : SelectFilterAction + data class SelectKeyword(val keyword: Keyword) : SelectFilterAction + data class SearchKeywords(val query: String) : SelectFilterAction } diff --git a/feature/discover/src/commonMain/kotlin/com/divinelink/feature/discover/filters/SelectFilterModalBottomSheet.kt b/feature/discover/src/commonMain/kotlin/com/divinelink/feature/discover/filters/SelectFilterModalBottomSheet.kt index 4e2ca49af..25afad56e 100644 --- a/feature/discover/src/commonMain/kotlin/com/divinelink/feature/discover/filters/SelectFilterModalBottomSheet.kt +++ b/feature/discover/src/commonMain/kotlin/com/divinelink/feature/discover/filters/SelectFilterModalBottomSheet.kt @@ -16,6 +16,7 @@ import com.divinelink.core.ui.resources.core_ui_country import com.divinelink.core.ui.resources.core_ui_language import com.divinelink.feature.discover.FilterModal import com.divinelink.feature.discover.FilterType +import com.divinelink.feature.discover.filters.keyword.KeywordsFiltersContent import com.divinelink.feature.discover.filters.year.YearFilterContent import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel @@ -86,7 +87,12 @@ fun SelectFilterModalBottomSheet( ) is FilterType.Year -> YearFilterContent( filter = filterType, - action = { viewModel.onAction(it) }, + action = viewModel::onAction, + ) + is FilterType.Keywords -> KeywordsFiltersContent( + keywords = filterType, + action = viewModel::onAction, + onDismissRequest = onDismissRequest, ) } } diff --git a/feature/discover/src/commonMain/kotlin/com/divinelink/feature/discover/filters/SelectFilterUiState.kt b/feature/discover/src/commonMain/kotlin/com/divinelink/feature/discover/filters/SelectFilterUiState.kt index b908bbfe0..64372d348 100644 --- a/feature/discover/src/commonMain/kotlin/com/divinelink/feature/discover/filters/SelectFilterUiState.kt +++ b/feature/discover/src/commonMain/kotlin/com/divinelink/feature/discover/filters/SelectFilterUiState.kt @@ -45,6 +45,12 @@ data class SelectFilterUiState( lessThan = 10, minimumVotes = 10, ) + FilterModal.Keywords -> FilterType.Keywords( + options = emptyList(), + selectedOptions = emptyList(), + query = null, + loading = false, + ) }, ) } diff --git a/feature/discover/src/commonMain/kotlin/com/divinelink/feature/discover/filters/SelectFilterViewModel.kt b/feature/discover/src/commonMain/kotlin/com/divinelink/feature/discover/filters/SelectFilterViewModel.kt index 67627e417..5b1fefb5e 100644 --- a/feature/discover/src/commonMain/kotlin/com/divinelink/feature/discover/filters/SelectFilterViewModel.kt +++ b/feature/discover/src/commonMain/kotlin/com/divinelink/feature/discover/filters/SelectFilterViewModel.kt @@ -10,9 +10,12 @@ import com.divinelink.core.model.discover.YearType import com.divinelink.core.model.exception.AppException import com.divinelink.core.model.media.MediaType import com.divinelink.core.network.Resource +import com.divinelink.core.network.media.model.search.movie.SearchRequestApi import com.divinelink.core.ui.blankslate.BlankSlateState import com.divinelink.feature.discover.FilterModal import com.divinelink.feature.discover.FilterType +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.distinctUntilChanged @@ -37,6 +40,8 @@ class SelectFilterViewModel( ) val uiState: StateFlow = _uiState + private var searchJob: Job? = null + init { when (type) { FilterModal.Country -> @@ -122,31 +127,45 @@ class SelectFilterViewModel( } .launchIn(viewModelScope) } - FilterModal.Year -> - filterRepository - .year - .map { it[_uiState.value.mediaType] } - .distinctUntilChanged() - .onEach { filter -> - _uiState.update { uiState -> - when (filter) { - is DiscoverFilter.Year.Range -> uiState.copy( - filterType = FilterType.Year.Range( - startYear = filter.startYear, - endYear = filter.endYear, - ), - ) - is DiscoverFilter.Year.Decade -> uiState.copy( - filterType = FilterType.Year.Decade(decade = filter.decade), - ) - is DiscoverFilter.Year.Single -> uiState.copy( - filterType = FilterType.Year.Single(year = filter.year), - ) - null -> uiState.copy(filterType = FilterType.Year.Any) - } + FilterModal.Year -> filterRepository + .year + .map { it[_uiState.value.mediaType] } + .distinctUntilChanged() + .onEach { filter -> + _uiState.update { uiState -> + when (filter) { + is DiscoverFilter.Year.Range -> uiState.copy( + filterType = FilterType.Year.Range( + startYear = filter.startYear, + endYear = filter.endYear, + ), + ) + is DiscoverFilter.Year.Decade -> uiState.copy( + filterType = FilterType.Year.Decade(decade = filter.decade), + ) + is DiscoverFilter.Year.Single -> uiState.copy( + filterType = FilterType.Year.Single(year = filter.year), + ) + null -> uiState.copy(filterType = FilterType.Year.Any) } } - .launchIn(viewModelScope) + } + .launchIn(viewModelScope) + + FilterModal.Keywords -> filterRepository + .keywords + .map { it[_uiState.value.mediaType] ?: emptyList() } + .distinctUntilChanged() + .onEach { keywords -> + _uiState.update { + it.copy( + filterType = (it.filterType as FilterType.Keywords).copy( + selectedOptions = keywords, + ), + ) + } + } + .launchIn(viewModelScope) } } @@ -206,6 +225,7 @@ class SelectFilterViewModel( fun onAction(action: SelectFilterAction) { when (action) { SelectFilterAction.ClearGenres -> handleClearGenres() + SelectFilterAction.ClearKeywords -> handleClearKeywords() SelectFilterAction.ResetRatingFilters -> handleResetRatings() SelectFilterAction.Retry -> handleRetry() is SelectFilterAction.SelectGenre -> handleSelectGenre(action) @@ -219,6 +239,48 @@ class SelectFilterViewModel( is SelectFilterAction.UpdateStartYear -> handleUpdateStartYear(action.startYear) is SelectFilterAction.UpdateEndYear -> handleUpdateEndYear(action.endYear) is SelectFilterAction.OnSelectDecade -> handleSelectDecade(action.decade) + is SelectFilterAction.SearchKeywords -> handleSearchKeywords(action) + is SelectFilterAction.SelectKeyword -> handleSelectKeyword(action) + } + } + + private fun handleSelectKeyword(action: SelectFilterAction.SelectKeyword) { + filterRepository.updateKeyword( + mediaType = _uiState.value.mediaType, + keyword = action.keyword, + ) + } + + private fun handleSearchKeywords(action: SelectFilterAction.SearchKeywords) { + searchJob?.cancel() + + _uiState.update { state -> + state.copy( + filterType = (state.filterType as FilterType.Keywords).copy( + loading = true, + query = action.query, + ), + ) + } + + searchJob = viewModelScope.launch { + delay(300) + + repository.fetchSearchKeywords( + request = SearchRequestApi( + query = action.query, + page = 1, + ), + ).map { result -> + _uiState.update { state -> + state.copy( + filterType = (state.filterType as FilterType.Keywords).copy( + options = result.list, + loading = false, + ), + ) + } + } } } @@ -231,6 +293,7 @@ class SelectFilterViewModel( is FilterType.Searchable.Languages -> uiState.filterType.copy(query = action.query) is FilterType.VoteAverage -> uiState.filterType is FilterType.Year -> uiState.filterType + is FilterType.Keywords -> uiState.filterType.copy(query = action.query) }, ) } @@ -243,6 +306,12 @@ class SelectFilterViewModel( ) } + private fun handleClearKeywords() { + filterRepository.clearKeywords( + mediaType = _uiState.value.mediaType, + ) + } + private fun handleResetRatings() { filterRepository.clearRatings(mediaType = _uiState.value.mediaType) } @@ -254,6 +323,7 @@ class SelectFilterViewModel( FilterModal.Language -> Unit FilterModal.VoteAverage -> Unit FilterModal.Year -> Unit + FilterModal.Keywords -> Unit } } diff --git a/feature/discover/src/commonMain/kotlin/com/divinelink/feature/discover/filters/keyword/KeywordsFiltersContent.kt b/feature/discover/src/commonMain/kotlin/com/divinelink/feature/discover/filters/keyword/KeywordsFiltersContent.kt new file mode 100644 index 000000000..d55dc53c6 --- /dev/null +++ b/feature/discover/src/commonMain/kotlin/com/divinelink/feature/discover/filters/keyword/KeywordsFiltersContent.kt @@ -0,0 +1,155 @@ +package com.divinelink.feature.discover.filters.keyword + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.ElevatedButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.InputChip +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.divinelink.core.designsystem.theme.dimensions +import com.divinelink.core.model.UIText +import com.divinelink.core.ui.UiString +import com.divinelink.core.ui.blankslate.BlankSlate +import com.divinelink.core.ui.blankslate.BlankSlateState +import com.divinelink.core.ui.resources.core_ui_clear_all +import com.divinelink.core.ui.resources.core_ui_keywords +import com.divinelink.core.ui.resources.core_ui_search_keywords_description +import com.divinelink.feature.discover.FilterType +import com.divinelink.feature.discover.filters.SelectFilterAction +import com.divinelink.feature.discover.ui.SearchField +import org.jetbrains.compose.resources.stringResource + +@Composable +fun KeywordsFiltersContent( + keywords: FilterType.Keywords, + action: (SelectFilterAction) -> Unit, + onDismissRequest: () -> Unit, +) { + val density = LocalDensity.current + + Box { + var actionsSize by remember { mutableStateOf(0.dp) } + + LazyColumn( + modifier = Modifier + .padding(bottom = actionsSize.plus(MaterialTheme.dimensions.keyline_8)) + .fillMaxSize(), + contentPadding = PaddingValues(MaterialTheme.dimensions.keyline_16), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.dimensions.keyline_4), + ) { + + item { + Text( + textAlign = TextAlign.Start, + text = stringResource(UiString.core_ui_keywords), + style = MaterialTheme.typography.titleMedium, + ) + } + + stickyHeader { + SearchField( + modifier = Modifier + .fillMaxWidth() + .background( + color = MaterialTheme.colorScheme.surfaceContainerLow, + ) + .padding(top = MaterialTheme.dimensions.keyline_8) + .padding(vertical = MaterialTheme.dimensions.keyline_16), + value = keywords.query, + onValueChange = { action(SelectFilterAction.SearchKeywords(it)) }, + ) + + Box { + AnimatedContent( + targetState = keywords.loading, + ) { loading -> + if (loading) { + AnimatedVisibility( + modifier = Modifier.align(Alignment.Center), + visible = keywords.loading, + ) { + LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + } + } else { + HorizontalDivider(modifier = Modifier.align(Alignment.Center)) + } + } + } + } + + if (keywords.visibleOptions.isEmpty()) { + item { + BlankSlate( + uiState = BlankSlateState.Custom( + title = UIText.StringText(""), + description = UIText.ResourceText(UiString.core_ui_search_keywords_description), + ), + ) + } + } + + item { + FlowRow( + modifier = Modifier.matchParentSize(), + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.dimensions.keyline_4), + ) { + keywords.visibleOptions.forEach { keyword -> + key(keyword.id) { + InputChip( + modifier = Modifier.animateItem(), + selected = keyword in keywords.selectedOptions, + onClick = { action(SelectFilterAction.SelectKeyword(keyword)) }, + label = { Text(keyword.name) }, + ) + } + } + } + } + } + + Row( + modifier = Modifier + .onSizeChanged { with(density) { actionsSize = it.height.toDp() } } + .padding(horizontal = MaterialTheme.dimensions.keyline_16) + .align(Alignment.BottomCenter) + .fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.dimensions.keyline_16), + verticalAlignment = Alignment.CenterVertically, + ) { + ElevatedButton( + enabled = keywords.visibleOptions.isNotEmpty(), + modifier = Modifier.weight(1f), + onClick = { + action(SelectFilterAction.ClearKeywords) + onDismissRequest() + }, + ) { + Text(text = stringResource(UiString.core_ui_clear_all)) + } + } + } +} diff --git a/feature/discover/src/commonMain/kotlin/com/divinelink/feature/discover/ui/DiscoverContent.kt b/feature/discover/src/commonMain/kotlin/com/divinelink/feature/discover/ui/DiscoverContent.kt index cbcaad515..4dac46883 100644 --- a/feature/discover/src/commonMain/kotlin/com/divinelink/feature/discover/ui/DiscoverContent.kt +++ b/feature/discover/src/commonMain/kotlin/com/divinelink/feature/discover/ui/DiscoverContent.kt @@ -33,12 +33,15 @@ import com.divinelink.core.navigation.route.Navigation import com.divinelink.core.navigation.utilities.toRoute import com.divinelink.core.ui.Previews import com.divinelink.core.ui.UiDrawable +import com.divinelink.core.ui.UiString import com.divinelink.core.ui.blankslate.BlankSlate import com.divinelink.core.ui.blankslate.BlankSlateState import com.divinelink.core.ui.components.LoadingContent import com.divinelink.core.ui.components.clearButton import com.divinelink.core.ui.composition.PreviewLocalProvider import com.divinelink.core.ui.list.ScrollableMediaContent +import com.divinelink.core.ui.resources.core_ui_genres +import com.divinelink.core.ui.resources.core_ui_keywords import com.divinelink.core.ui.resources.no_results import com.divinelink.core.ui.tab.ScenePeekSecondaryTabs import com.divinelink.feature.discover.DiscoverAction @@ -108,12 +111,14 @@ fun DiscoverContent( ) item { - DiscoverFilterChip.Genre( + DiscoverFilterChip.MultiSelect( modifier = Modifier .animateItem() .animateContentSize(), filters = uiState.currentFilters.genres, onClick = { filterModal = FilterModal.Genre }, + title = UiString.core_ui_genres, + name = uiState.currentFilters.genres.firstOrNull()?.name, ) } @@ -157,6 +162,18 @@ fun DiscoverContent( onClick = { filterModal = FilterModal.VoteAverage }, ) } + + item { + DiscoverFilterChip.MultiSelect( + modifier = Modifier + .animateItem() + .animateContentSize(), + filters = uiState.currentFilters.keywords, + onClick = { filterModal = FilterModal.Keywords }, + title = UiString.core_ui_keywords, + name = uiState.currentFilters.keywords.firstOrNull()?.name, + ) + } } HorizontalPager( From fc62bc81ff6cc178794d358d01e084e70744317a Mon Sep 17 00:00:00 2001 From: Harry Andreolas Date: Sun, 15 Feb 2026 15:55:39 +0200 Subject: [PATCH 5/6] feat: make filter repository unique for each instance --- .../divinelink/core/data/FilterRepository.kt | 178 +++++++++++------- .../network/media/service/ProdMediaService.kt | 9 +- .../core/network/media/util/BuildUrl.kt | 4 +- .../core/navigation/route/Navigation.kt | 1 + .../core/ui/components/DiscoverFab.kt | 4 + .../media/ui/forms/about/AboutFormContent.kt | 5 + .../feature/discover/DiscoverUiState.kt | 2 + .../feature/discover/DiscoverViewModel.kt | 68 ++++--- .../filters/SelectFilterModalBottomSheet.kt | 5 +- .../discover/filters/SelectFilterViewModel.kt | 98 ++++++---- .../filters/keyword/KeywordsFiltersContent.kt | 1 - .../feature/discover/ui/DiscoverContent.kt | 1 + 12 files changed, 224 insertions(+), 152 deletions(-) diff --git a/core/data/src/commonMain/kotlin/com/divinelink/core/data/FilterRepository.kt b/core/data/src/commonMain/kotlin/com/divinelink/core/data/FilterRepository.kt index 271b2a361..5426830b0 100644 --- a/core/data/src/commonMain/kotlin/com/divinelink/core/data/FilterRepository.kt +++ b/core/data/src/commonMain/kotlin/com/divinelink/core/data/FilterRepository.kt @@ -11,110 +11,138 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow class FilterRepository { - private val _selectedGenres = MutableStateFlow>>( - mapOf( - MediaType.MOVIE to emptyList(), - MediaType.TV to emptyList(), + private val filterStates = mutableMapOf() + + private data class FilterState( + val selectedGenres: MutableStateFlow>> = MutableStateFlow( + mapOf( + MediaType.MOVIE to emptyList(), + MediaType.TV to emptyList(), + ), ), - ) - val selectedGenres: StateFlow>> = _selectedGenres.asStateFlow() - - private val _selectedLanguage = MutableStateFlow>( - mapOf( - MediaType.MOVIE to null, - MediaType.TV to null, + val selectedLanguage: MutableStateFlow> = MutableStateFlow( + mapOf( + MediaType.MOVIE to null, + MediaType.TV to null, + ), ), - ) - val selectedLanguage: StateFlow> = _selectedLanguage.asStateFlow() - - private val _voteAverage = MutableStateFlow>( - mapOf( - MediaType.MOVIE to null, - MediaType.TV to null, + val voteAverage: MutableStateFlow> = + MutableStateFlow( + mapOf( + MediaType.MOVIE to null, + MediaType.TV to null, + ), + ), + val selectedCountry: MutableStateFlow> = MutableStateFlow( + mapOf( + MediaType.MOVIE to null, + MediaType.TV to null, + ), ), - ) - val voteAverage: StateFlow> = _voteAverage - .asStateFlow() - - private val _selectedCountry = MutableStateFlow>( - mapOf( - MediaType.MOVIE to null, - MediaType.TV to null, + val minimumVotes: MutableStateFlow> = MutableStateFlow( + mapOf( + MediaType.MOVIE to null, + MediaType.TV to null, + ), ), - ) - val selectedCountry: StateFlow> = _selectedCountry.asStateFlow() - - private val _minimumVotes = MutableStateFlow>( - mapOf( - MediaType.MOVIE to null, - MediaType.TV to null, + val year: MutableStateFlow> = MutableStateFlow( + mapOf( + MediaType.MOVIE to null, + MediaType.TV to null, + ), ), - ) - val minimumVotes: StateFlow> = _minimumVotes.asStateFlow() - - private val _year = MutableStateFlow>( - mapOf( - MediaType.MOVIE to null, - MediaType.TV to null, + val keywords: MutableStateFlow>> = MutableStateFlow( + mapOf( + MediaType.MOVIE to emptyList(), + MediaType.TV to emptyList(), + ), ), ) - val year: StateFlow> = _year.asStateFlow() - private val _keywords = MutableStateFlow>>( - mapOf( - MediaType.MOVIE to emptyList(), - MediaType.TV to emptyList(), - ), - ) - val keywords: StateFlow>> = _keywords.asStateFlow() + private fun getFilterState(uuid: String): FilterState = filterStates.getOrPut(uuid) { + FilterState() + } + + fun clearFilterState(uuid: String) { + filterStates.remove(uuid) + } + + fun selectedGenres(uuid: String): StateFlow>> = + getFilterState(uuid).selectedGenres.asStateFlow() + + fun selectedLanguage(uuid: String): StateFlow> = + getFilterState(uuid).selectedLanguage.asStateFlow() + + fun voteAverage(uuid: String): StateFlow> = + getFilterState(uuid).voteAverage.asStateFlow() + + fun selectedCountry(uuid: String): StateFlow> = + getFilterState(uuid).selectedCountry.asStateFlow() + + fun minimumVotes(uuid: String): StateFlow> = + getFilterState(uuid).minimumVotes.asStateFlow() + + fun year(uuid: String): StateFlow> = + getFilterState(uuid).year.asStateFlow() + + fun keywords(uuid: String): StateFlow>> = + getFilterState(uuid).keywords.asStateFlow() fun updateSelectedGenres( + uuid: String, mediaType: MediaType, genres: List, ) { - _selectedGenres.value += mediaType to genres + getFilterState(uuid).selectedGenres.value += mediaType to genres } fun updateLanguage( + uuid: String, mediaType: MediaType, language: Language?, ) { - _selectedLanguage.value += mediaType to language + getFilterState(uuid).selectedLanguage.value += mediaType to language } fun updateCountry( + uuid: String, mediaType: MediaType, country: Country?, ) { - _selectedCountry.value += mediaType to country + getFilterState(uuid).selectedCountry.value += mediaType to country } fun updateVoteAverage( + uuid: String, mediaType: MediaType, voteAverage: DiscoverFilter.VoteAverage?, ) { - _voteAverage.value += mediaType to voteAverage + getFilterState(uuid).voteAverage.value += mediaType to voteAverage } fun updateMinimumVotes( + uuid: String, mediaType: MediaType, votes: Int, ) { - _minimumVotes.value += mediaType to votes + getFilterState(uuid).minimumVotes.value += mediaType to votes } fun updateYear( + uuid: String, mediaType: MediaType, year: DiscoverFilter.Year?, ) { - _year.value += mediaType to year + getFilterState(uuid).year.value += mediaType to year } fun updateKeyword( + uuid: String, mediaType: MediaType, keyword: Keyword, ) { - val currentKeywords = _keywords.value[mediaType] ?: emptyList() + val filterState = getFilterState(uuid) + val currentKeywords = filterState.keywords.value[mediaType] ?: emptyList() val keywords = if (keyword in currentKeywords) { currentKeywords - keyword @@ -122,25 +150,37 @@ class FilterRepository { currentKeywords + keyword } - _keywords.value += mediaType to keywords + filterState.keywords.value += mediaType to keywords } - fun clearKeywords(mediaType: MediaType) { - _keywords.value += mediaType to emptyList() + fun clearKeywords( + uuid: String, + mediaType: MediaType, + ) { + getFilterState(uuid).keywords.value += mediaType to emptyList() } - fun clearRatings(mediaType: MediaType) { - _voteAverage.value += mediaType to null - _minimumVotes.value += mediaType to null + fun clearRatings( + uuid: String, + mediaType: MediaType, + ) { + getFilterState(uuid).voteAverage.value += mediaType to null + getFilterState(uuid).minimumVotes.value += mediaType to null } - fun clear(mediaType: MediaType) { - _selectedGenres.value += mediaType to emptyList() - _keywords.value += mediaType to emptyList() - _selectedLanguage.value += mediaType to null - _selectedCountry.value += mediaType to null - _voteAverage.value += mediaType to null - _minimumVotes.value += mediaType to null - _year.value += mediaType to null + fun clear( + uuid: String, + mediaType: MediaType, + ) { + with(getFilterState(uuid)) { + selectedGenres.value += mediaType to emptyList() + keywords.value += mediaType to emptyList() + selectedLanguage.value += mediaType to null + selectedCountry.value += mediaType to null + voteAverage.value += mediaType to null + minimumVotes.value += mediaType to null + year.value += mediaType to null + } + clearFilterState(uuid) } } diff --git a/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/service/ProdMediaService.kt b/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/service/ProdMediaService.kt index 7dab47b58..eeada00b4 100644 --- a/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/service/ProdMediaService.kt +++ b/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/service/ProdMediaService.kt @@ -281,9 +281,8 @@ class ProdMediaService( restClient.get(url = buildCollectionsUrl(id = id)) } - override suspend fun searchKeywords( - request: SearchRequestApi, - ): Result = runCatching { - restClient.get(url = buildSearchKeywordUrl(request = request)) - } + override suspend fun searchKeywords(request: SearchRequestApi): Result = + runCatching { + restClient.get(url = buildSearchKeywordUrl(request = request)) + } } diff --git a/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/util/BuildUrl.kt b/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/util/BuildUrl.kt index 77bfab29e..ac96260b0 100644 --- a/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/util/BuildUrl.kt +++ b/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/util/BuildUrl.kt @@ -161,9 +161,7 @@ fun buildCollectionsUrl(id: Int): String = buildUrl { } }.toString() -fun buildSearchKeywordUrl( - request: SearchRequestApi, -): String = buildUrl { +fun buildSearchKeywordUrl(request: SearchRequestApi): String = buildUrl { protocol = URLProtocol.HTTPS host = Routes.TMDb.HOST encodedPath = Routes.TMDb.V3 + "/search/keyword" diff --git a/core/scaffold/src/commonMain/kotlin/com/divinelink/core/navigation/route/Navigation.kt b/core/scaffold/src/commonMain/kotlin/com/divinelink/core/navigation/route/Navigation.kt index 064519d3c..5cbb04bd9 100644 --- a/core/scaffold/src/commonMain/kotlin/com/divinelink/core/navigation/route/Navigation.kt +++ b/core/scaffold/src/commonMain/kotlin/com/divinelink/core/navigation/route/Navigation.kt @@ -150,6 +150,7 @@ sealed interface Navigation { @Serializable data class DiscoverRoute( val mediaType: String?, + val entryPointUuid: String, val encodedGenre: String? = null, val encodedKeyword: String? = null, ) : Navigation diff --git a/core/scaffold/src/commonMain/kotlin/com/divinelink/core/ui/components/DiscoverFab.kt b/core/scaffold/src/commonMain/kotlin/com/divinelink/core/ui/components/DiscoverFab.kt index 0c38c3126..68d426a6d 100644 --- a/core/scaffold/src/commonMain/kotlin/com/divinelink/core/ui/components/DiscoverFab.kt +++ b/core/scaffold/src/commonMain/kotlin/com/divinelink/core/ui/components/DiscoverFab.kt @@ -12,7 +12,10 @@ import com.divinelink.core.ui.TestTags import com.divinelink.core.ui.UiString import com.divinelink.core.ui.resources.core_ui_discover import org.jetbrains.compose.resources.stringResource +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid +@OptIn(ExperimentalUuidApi::class) @Composable fun ScaffoldState.DiscoverFab( expanded: Boolean, @@ -26,6 +29,7 @@ fun ScaffoldState.DiscoverFab( onClick = { onNavigate( Navigation.DiscoverRoute( + entryPointUuid = Uuid.random().toHexString(), mediaType = null, encodedGenre = null, encodedKeyword = null, diff --git a/feature/details/src/commonMain/kotlin/com/divinelink/feature/details/media/ui/forms/about/AboutFormContent.kt b/feature/details/src/commonMain/kotlin/com/divinelink/feature/details/media/ui/forms/about/AboutFormContent.kt index aef551420..071905aea 100644 --- a/feature/details/src/commonMain/kotlin/com/divinelink/feature/details/media/ui/forms/about/AboutFormContent.kt +++ b/feature/details/src/commonMain/kotlin/com/divinelink/feature/details/media/ui/forms/about/AboutFormContent.kt @@ -26,7 +26,10 @@ import com.divinelink.feature.details.media.ui.components.GenresSection import com.divinelink.feature.details.media.ui.components.KeywordsSection import com.divinelink.feature.details.media.ui.components.MovieInformationSection import com.divinelink.feature.details.media.ui.components.TvInformationSection +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid +@OptIn(ExperimentalUuidApi::class) @Composable fun AboutFormContent( modifier: Modifier = Modifier, @@ -73,6 +76,7 @@ fun AboutFormContent( onClick = { onNavigate( Navigation.DiscoverRoute( + entryPointUuid = Uuid.random().toHexString(), mediaType = aboutData.mediaType.value, encodedGenre = it.encodeToString(), ), @@ -145,6 +149,7 @@ fun AboutFormContent( onClick = { onNavigate( Navigation.DiscoverRoute( + entryPointUuid = Uuid.random().toHexString(), mediaType = aboutData.mediaType.value, encodedKeyword = it.encodeToString(), ), diff --git a/feature/discover/src/commonMain/kotlin/com/divinelink/feature/discover/DiscoverUiState.kt b/feature/discover/src/commonMain/kotlin/com/divinelink/feature/discover/DiscoverUiState.kt index 10d140fed..13e388fa8 100644 --- a/feature/discover/src/commonMain/kotlin/com/divinelink/feature/discover/DiscoverUiState.kt +++ b/feature/discover/src/commonMain/kotlin/com/divinelink/feature/discover/DiscoverUiState.kt @@ -8,6 +8,7 @@ import com.divinelink.core.model.tab.MediaTab import com.divinelink.core.navigation.route.Navigation data class DiscoverUiState( + val uuid: String, val selectedTabIndex: Int, val tabs: List, val pages: Map, @@ -22,6 +23,7 @@ data class DiscoverUiState( val mediaType = MediaType.from(route.mediaType) return DiscoverUiState( + uuid = route.entryPointUuid, selectedTabIndex = when (mediaType) { MediaType.MOVIE -> MediaTab.Movie.order MediaType.TV -> MediaTab.TV.order diff --git a/feature/discover/src/commonMain/kotlin/com/divinelink/feature/discover/DiscoverViewModel.kt b/feature/discover/src/commonMain/kotlin/com/divinelink/feature/discover/DiscoverViewModel.kt index 3cc47c0ca..dc6874118 100644 --- a/feature/discover/src/commonMain/kotlin/com/divinelink/feature/discover/DiscoverViewModel.kt +++ b/feature/discover/src/commonMain/kotlin/com/divinelink/feature/discover/DiscoverViewModel.kt @@ -26,6 +26,8 @@ import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid class DiscoverViewModel( private val filterRepository: FilterRepository, @@ -34,7 +36,9 @@ class DiscoverViewModel( savedStateHandle: SavedStateHandle, ) : ViewModel() { + @OptIn(ExperimentalUuidApi::class) private val route = Navigation.DiscoverRoute( + entryPointUuid = savedStateHandle.get("entryPointUuid") ?: Uuid.random().toHexString(), mediaType = savedStateHandle.get("mediaType"), encodedGenre = savedStateHandle.get("encodedGenre"), encodedKeyword = savedStateHandle.get("encodedKeyword"), @@ -47,8 +51,6 @@ class DiscoverViewModel( init { viewModelScope.launch { - filterRepository.clear(_uiState.value.selectedMedia) - val mediaType = MediaType.from(route.mediaType) val genre = route.encodedGenre?.decodeFromString() val keyword = route.encodedKeyword?.decodeFromString() @@ -56,6 +58,7 @@ class DiscoverViewModel( genre?.let { genre -> filterRepository.updateSelectedGenres( mediaType = mediaType, + uuid = route.entryPointUuid, genres = listOf(genre), ) } @@ -63,32 +66,14 @@ class DiscoverViewModel( keyword?.let { keyword -> filterRepository.updateKeyword( mediaType = mediaType, + uuid = route.entryPointUuid, keyword = keyword, ) } } - preferencesRepository - .uiPreferences - .mapNotNull { uiPreferences -> - uiPreferences.sortOption.mapNotNull { (key, value) -> - when (key) { - ViewableSection.DISCOVER_SHOWS -> MediaType.TV to value - ViewableSection.DISCOVER_MOVIES -> MediaType.MOVIE to value - else -> null - } - }.toMap() - } - .distinctUntilChanged() - .onEach { sortMap -> - _uiState.update { uiState -> uiState.copy(sortOption = sortMap) } - - handleDiscoverMedia(reset = true) - } - .launchIn(viewModelScope) - filterRepository - .selectedGenres + .selectedGenres(route.entryPointUuid) .map { it[uiState.value.selectedMedia] ?: emptyList() } .onEach { genres -> _uiState.update { uiState -> @@ -103,7 +88,7 @@ class DiscoverViewModel( .launchIn(viewModelScope) filterRepository - .selectedLanguage + .selectedLanguage(route.entryPointUuid) .map { it[uiState.value.selectedMedia] } .onEach { language -> _uiState.update { uiState -> @@ -118,7 +103,7 @@ class DiscoverViewModel( .launchIn(viewModelScope) filterRepository - .selectedCountry + .selectedCountry(route.entryPointUuid) .map { it[uiState.value.selectedMedia] } .onEach { country -> _uiState.update { uiState -> @@ -133,7 +118,7 @@ class DiscoverViewModel( .launchIn(viewModelScope) filterRepository - .voteAverage + .voteAverage(route.entryPointUuid) .map { it[uiState.value.selectedMedia] } .onEach { voteAverage -> _uiState.update { uiState -> @@ -148,7 +133,7 @@ class DiscoverViewModel( .launchIn(viewModelScope) filterRepository - .minimumVotes + .minimumVotes(route.entryPointUuid) .map { it[uiState.value.selectedMedia] } .onEach { votes -> _uiState.update { uiState -> @@ -163,7 +148,7 @@ class DiscoverViewModel( .launchIn(viewModelScope) filterRepository - .year + .year(route.entryPointUuid) .map { it[uiState.value.selectedMedia] } .onEach { filter -> _uiState.update { uiState -> @@ -178,7 +163,7 @@ class DiscoverViewModel( .launchIn(viewModelScope) filterRepository - .keywords + .keywords(route.entryPointUuid) .map { it[uiState.value.selectedMedia] ?: emptyList() } .onEach { keywords -> _uiState.update { uiState -> @@ -191,6 +176,24 @@ class DiscoverViewModel( } } .launchIn(viewModelScope) + + preferencesRepository + .uiPreferences + .mapNotNull { uiPreferences -> + uiPreferences.sortOption.mapNotNull { (key, value) -> + when (key) { + ViewableSection.DISCOVER_SHOWS -> MediaType.TV to value + ViewableSection.DISCOVER_MOVIES -> MediaType.MOVIE to value + else -> null + } + }.toMap() + } + .distinctUntilChanged() + .onEach { sortMap -> + _uiState.update { uiState -> uiState.copy(sortOption = sortMap) } + handleDiscoverMedia(reset = true) + } + .launchIn(viewModelScope) } fun onAction(action: DiscoverAction) { @@ -203,7 +206,10 @@ class DiscoverViewModel( } private fun handleClearFilters() { - filterRepository.clear(mediaType = _uiState.value.selectedMedia) + filterRepository.clear( + uuid = route.entryPointUuid, + mediaType = _uiState.value.selectedMedia, + ) handleDiscoverMedia(reset = true) } @@ -327,8 +333,8 @@ class DiscoverViewModel( override fun onCleared() { super.onCleared() - filterRepository.clear(mediaType = MediaType.TV) - filterRepository.clear(mediaType = MediaType.MOVIE) + filterRepository.clear(uuid = route.entryPointUuid, mediaType = MediaType.TV) + filterRepository.clear(uuid = route.entryPointUuid, mediaType = MediaType.MOVIE) } private fun Map.updateFilters( diff --git a/feature/discover/src/commonMain/kotlin/com/divinelink/feature/discover/filters/SelectFilterModalBottomSheet.kt b/feature/discover/src/commonMain/kotlin/com/divinelink/feature/discover/filters/SelectFilterModalBottomSheet.kt index 25afad56e..9c5381db4 100644 --- a/feature/discover/src/commonMain/kotlin/com/divinelink/feature/discover/filters/SelectFilterModalBottomSheet.kt +++ b/feature/discover/src/commonMain/kotlin/com/divinelink/feature/discover/filters/SelectFilterModalBottomSheet.kt @@ -26,10 +26,11 @@ import org.koin.core.parameter.parametersOf @Composable fun SelectFilterModalBottomSheet( type: FilterModal, + uuid: String, mediaType: MediaType, viewModel: SelectFilterViewModel = koinViewModel( - key = "SelectGenreModalBottomSheet-${mediaType.value}-$type", - ) { parametersOf(mediaType, type) }, + key = "SelectGenreModalBottomSheet-${mediaType.value}-$type-$uuid", + ) { parametersOf(mediaType, type, uuid) }, onDismissRequest: () -> Unit, ) { val density = LocalDensity.current diff --git a/feature/discover/src/commonMain/kotlin/com/divinelink/feature/discover/filters/SelectFilterViewModel.kt b/feature/discover/src/commonMain/kotlin/com/divinelink/feature/discover/filters/SelectFilterViewModel.kt index 5b1fefb5e..96de52ab0 100644 --- a/feature/discover/src/commonMain/kotlin/com/divinelink/feature/discover/filters/SelectFilterViewModel.kt +++ b/feature/discover/src/commonMain/kotlin/com/divinelink/feature/discover/filters/SelectFilterViewModel.kt @@ -28,6 +28,7 @@ import kotlinx.coroutines.launch class SelectFilterViewModel( type: FilterModal, mediaType: MediaType, + private val entryPointUuid: String, private val repository: MediaRepository, private val filterRepository: FilterRepository, ) : ViewModel() { @@ -46,7 +47,7 @@ class SelectFilterViewModel( when (type) { FilterModal.Country -> filterRepository - .selectedCountry + .selectedCountry(uuid = entryPointUuid) .map { it[_uiState.value.mediaType] } .distinctUntilChanged() .onEach { country -> @@ -64,7 +65,7 @@ class SelectFilterViewModel( FilterModal.Genre -> { fetchGenres(mediaType) filterRepository - .selectedGenres + .selectedGenres(uuid = entryPointUuid) .map { it[_uiState.value.mediaType] ?: emptyList() } .distinctUntilChanged() .onEach { genres -> @@ -80,7 +81,7 @@ class SelectFilterViewModel( } FilterModal.Language -> filterRepository - .selectedLanguage + .selectedLanguage(uuid = entryPointUuid) .map { it[_uiState.value.mediaType] } .distinctUntilChanged() .onEach { language -> @@ -97,7 +98,7 @@ class SelectFilterViewModel( FilterModal.VoteAverage -> { filterRepository - .voteAverage + .voteAverage(uuid = entryPointUuid) .map { it[_uiState.value.mediaType] } .distinctUntilChanged() .onEach { voteAverage -> @@ -113,7 +114,7 @@ class SelectFilterViewModel( .launchIn(viewModelScope) filterRepository - .minimumVotes + .minimumVotes(uuid = entryPointUuid) .map { it[uiState.value.mediaType] } .distinctUntilChanged() .onEach { votes -> @@ -127,45 +128,47 @@ class SelectFilterViewModel( } .launchIn(viewModelScope) } - FilterModal.Year -> filterRepository - .year - .map { it[_uiState.value.mediaType] } - .distinctUntilChanged() - .onEach { filter -> - _uiState.update { uiState -> - when (filter) { - is DiscoverFilter.Year.Range -> uiState.copy( - filterType = FilterType.Year.Range( - startYear = filter.startYear, - endYear = filter.endYear, + FilterModal.Year -> + filterRepository + .year(uuid = entryPointUuid) + .map { it[_uiState.value.mediaType] } + .distinctUntilChanged() + .onEach { filter -> + _uiState.update { uiState -> + when (filter) { + is DiscoverFilter.Year.Range -> uiState.copy( + filterType = FilterType.Year.Range( + startYear = filter.startYear, + endYear = filter.endYear, + ), + ) + is DiscoverFilter.Year.Decade -> uiState.copy( + filterType = FilterType.Year.Decade(decade = filter.decade), + ) + is DiscoverFilter.Year.Single -> uiState.copy( + filterType = FilterType.Year.Single(year = filter.year), + ) + null -> uiState.copy(filterType = FilterType.Year.Any) + } + } + } + .launchIn(viewModelScope) + + FilterModal.Keywords -> + filterRepository + .keywords(uuid = entryPointUuid) + .map { it[_uiState.value.mediaType] ?: emptyList() } + .distinctUntilChanged() + .onEach { keywords -> + _uiState.update { + it.copy( + filterType = (it.filterType as FilterType.Keywords).copy( + selectedOptions = keywords, ), ) - is DiscoverFilter.Year.Decade -> uiState.copy( - filterType = FilterType.Year.Decade(decade = filter.decade), - ) - is DiscoverFilter.Year.Single -> uiState.copy( - filterType = FilterType.Year.Single(year = filter.year), - ) - null -> uiState.copy(filterType = FilterType.Year.Any) } } - } - .launchIn(viewModelScope) - - FilterModal.Keywords -> filterRepository - .keywords - .map { it[_uiState.value.mediaType] ?: emptyList() } - .distinctUntilChanged() - .onEach { keywords -> - _uiState.update { - it.copy( - filterType = (it.filterType as FilterType.Keywords).copy( - selectedOptions = keywords, - ), - ) - } - } - .launchIn(viewModelScope) + .launchIn(viewModelScope) } } @@ -247,6 +250,7 @@ class SelectFilterViewModel( private fun handleSelectKeyword(action: SelectFilterAction.SelectKeyword) { filterRepository.updateKeyword( mediaType = _uiState.value.mediaType, + uuid = entryPointUuid, keyword = action.keyword, ) } @@ -302,18 +306,20 @@ class SelectFilterViewModel( private fun handleClearGenres() { filterRepository.updateSelectedGenres( mediaType = _uiState.value.mediaType, + uuid = entryPointUuid, genres = emptyList(), ) } private fun handleClearKeywords() { filterRepository.clearKeywords( + uuid = entryPointUuid, mediaType = _uiState.value.mediaType, ) } private fun handleResetRatings() { - filterRepository.clearRatings(mediaType = _uiState.value.mediaType) + filterRepository.clearRatings(uuid = entryPointUuid, mediaType = _uiState.value.mediaType) } private fun handleRetry() { @@ -338,6 +344,7 @@ class SelectFilterViewModel( filterRepository.updateSelectedGenres( mediaType = _uiState.value.mediaType, + uuid = entryPointUuid, genres = genres, ) } @@ -353,6 +360,7 @@ class SelectFilterViewModel( } filterRepository.updateLanguage( mediaType = _uiState.value.mediaType, + uuid = entryPointUuid, language = language, ) } @@ -368,6 +376,7 @@ class SelectFilterViewModel( } filterRepository.updateCountry( mediaType = _uiState.value.mediaType, + uuid = entryPointUuid, country = country, ) } @@ -375,6 +384,7 @@ class SelectFilterViewModel( private fun handleUpdateVoteRange(action: SelectFilterAction.UpdateVoteRange) { filterRepository.updateVoteAverage( mediaType = _uiState.value.mediaType, + uuid = entryPointUuid, voteAverage = action.voteAverage, ) } @@ -382,6 +392,7 @@ class SelectFilterViewModel( private fun handleUpdateMinimumVotes(action: SelectFilterAction.UpdateMinimumVotes) { filterRepository.updateMinimumVotes( mediaType = _uiState.value.mediaType, + uuid = entryPointUuid, votes = action.votes, ) } @@ -399,6 +410,7 @@ class SelectFilterViewModel( filterRepository.updateYear( mediaType = _uiState.value.mediaType, + uuid = entryPointUuid, year = filter, ) } @@ -406,6 +418,7 @@ class SelectFilterViewModel( private fun handleUpdateSingleYear(year: Int) { filterRepository.updateYear( mediaType = _uiState.value.mediaType, + uuid = entryPointUuid, year = DiscoverFilter.Year.Single(year = year), ) } @@ -413,6 +426,7 @@ class SelectFilterViewModel( private fun handleSelectDecade(decade: Decade) { filterRepository.updateYear( mediaType = _uiState.value.mediaType, + uuid = entryPointUuid, year = DiscoverFilter.Year.Decade(decade), ) } @@ -424,6 +438,7 @@ class SelectFilterViewModel( filterRepository.updateYear( mediaType = _uiState.value.mediaType, + uuid = entryPointUuid, year = DiscoverFilter.Year.Range( startYear = startYear, endYear = type.endYear, @@ -438,6 +453,7 @@ class SelectFilterViewModel( filterRepository.updateYear( mediaType = _uiState.value.mediaType, + uuid = entryPointUuid, year = DiscoverFilter.Year.Range( startYear = type.startYear, endYear = endYear, diff --git a/feature/discover/src/commonMain/kotlin/com/divinelink/feature/discover/filters/keyword/KeywordsFiltersContent.kt b/feature/discover/src/commonMain/kotlin/com/divinelink/feature/discover/filters/keyword/KeywordsFiltersContent.kt index d55dc53c6..67cbd35c4 100644 --- a/feature/discover/src/commonMain/kotlin/com/divinelink/feature/discover/filters/keyword/KeywordsFiltersContent.kt +++ b/feature/discover/src/commonMain/kotlin/com/divinelink/feature/discover/filters/keyword/KeywordsFiltersContent.kt @@ -61,7 +61,6 @@ fun KeywordsFiltersContent( contentPadding = PaddingValues(MaterialTheme.dimensions.keyline_16), verticalArrangement = Arrangement.spacedBy(MaterialTheme.dimensions.keyline_4), ) { - item { Text( textAlign = TextAlign.Start, diff --git a/feature/discover/src/commonMain/kotlin/com/divinelink/feature/discover/ui/DiscoverContent.kt b/feature/discover/src/commonMain/kotlin/com/divinelink/feature/discover/ui/DiscoverContent.kt index 4dac46883..fd853f7f8 100644 --- a/feature/discover/src/commonMain/kotlin/com/divinelink/feature/discover/ui/DiscoverContent.kt +++ b/feature/discover/src/commonMain/kotlin/com/divinelink/feature/discover/ui/DiscoverContent.kt @@ -79,6 +79,7 @@ fun DiscoverContent( filterModal?.let { type -> SelectFilterModalBottomSheet( type = type, + uuid = uiState.uuid, mediaType = uiState.selectedTab.mediaType, onDismissRequest = { filterModal = null From 3445c9b0eb324e44116846c29d13e50c64470d2d Mon Sep 17 00:00:00 2001 From: Harry Andreolas Date: Sun, 15 Feb 2026 16:13:41 +0200 Subject: [PATCH 6/6] feat: handle initial filter scroll position --- .../core/model/discover/MediaTypeFilters.kt | 11 ++++++++++ .../filters/keyword/KeywordsFiltersContent.kt | 21 +++++++++++++++++++ .../feature/discover/ui/DiscoverContent.kt | 7 +++++++ 3 files changed, 39 insertions(+) diff --git a/core/model/src/commonMain/kotlin/com/divinelink/core/model/discover/MediaTypeFilters.kt b/core/model/src/commonMain/kotlin/com/divinelink/core/model/discover/MediaTypeFilters.kt index 308b571dc..61108fbfe 100644 --- a/core/model/src/commonMain/kotlin/com/divinelink/core/model/discover/MediaTypeFilters.kt +++ b/core/model/src/commonMain/kotlin/com/divinelink/core/model/discover/MediaTypeFilters.kt @@ -34,4 +34,15 @@ data class MediaTypeFilters( voteAverage != null || votes != null || year != null + + val firstSelectedFilterIndex: Int = when { + genres.isNotEmpty() -> 0 + year != null -> 1 + language != null -> 2 + country != null -> 3 + voteAverage != null -> 4 + votes != null -> 5 + keywords.isNotEmpty() -> 6 + else -> 0 + } } diff --git a/feature/discover/src/commonMain/kotlin/com/divinelink/feature/discover/filters/keyword/KeywordsFiltersContent.kt b/feature/discover/src/commonMain/kotlin/com/divinelink/feature/discover/filters/keyword/KeywordsFiltersContent.kt index 67cbd35c4..74004bad0 100644 --- a/feature/discover/src/commonMain/kotlin/com/divinelink/feature/discover/filters/keyword/KeywordsFiltersContent.kt +++ b/feature/discover/src/commonMain/kotlin/com/divinelink/feature/discover/filters/keyword/KeywordsFiltersContent.kt @@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.ElevatedButton import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.InputChip @@ -19,15 +20,19 @@ import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.divinelink.core.designsystem.theme.dimensions @@ -41,6 +46,7 @@ import com.divinelink.core.ui.resources.core_ui_search_keywords_description import com.divinelink.feature.discover.FilterType import com.divinelink.feature.discover.filters.SelectFilterAction import com.divinelink.feature.discover.ui.SearchField +import kotlinx.coroutines.flow.distinctUntilChanged import org.jetbrains.compose.resources.stringResource @Composable @@ -49,7 +55,21 @@ fun KeywordsFiltersContent( action: (SelectFilterAction) -> Unit, onDismissRequest: () -> Unit, ) { + val state = rememberLazyListState() val density = LocalDensity.current + val keyboardController = LocalSoftwareKeyboardController.current + val focusManager = LocalFocusManager.current + + LaunchedEffect(state) { + snapshotFlow { state.isScrollInProgress } + .distinctUntilChanged() + .collect { isScrolling -> + if (isScrolling) { + keyboardController?.hide() + focusManager.clearFocus() + } + } + } Box { var actionsSize by remember { mutableStateOf(0.dp) } @@ -60,6 +80,7 @@ fun KeywordsFiltersContent( .fillMaxSize(), contentPadding = PaddingValues(MaterialTheme.dimensions.keyline_16), verticalArrangement = Arrangement.spacedBy(MaterialTheme.dimensions.keyline_4), + state = state, ) { item { Text( diff --git a/feature/discover/src/commonMain/kotlin/com/divinelink/feature/discover/ui/DiscoverContent.kt b/feature/discover/src/commonMain/kotlin/com/divinelink/feature/discover/ui/DiscoverContent.kt index fd853f7f8..5f7b6f8f8 100644 --- a/feature/discover/src/commonMain/kotlin/com/divinelink/feature/discover/ui/DiscoverContent.kt +++ b/feature/discover/src/commonMain/kotlin/com/divinelink/feature/discover/ui/DiscoverContent.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material3.MaterialTheme @@ -69,6 +70,7 @@ fun DiscoverContent( pageCount = { uiState.tabs.size }, ) var filterModal by remember { mutableStateOf(null) } + val filterState = rememberLazyListState() LaunchedEffect(pagerState) { snapshotFlow { pagerState.currentPage }.collect { page -> @@ -76,6 +78,10 @@ fun DiscoverContent( } } + LaunchedEffect(Unit) { + filterState.scrollToItem(uiState.currentFilters.firstSelectedFilterIndex) + } + filterModal?.let { type -> SelectFilterModalBottomSheet( type = type, @@ -105,6 +111,7 @@ fun DiscoverContent( start = MaterialTheme.dimensions.keyline_8, end = MaterialTheme.dimensions.keyline_16, ), + state = filterState, ) { clearButton( isVisible = uiState.currentFilters.hasSelectedFilters,