mirror of
https://github.com/bbara04/Purefin.git
synced 2026-07-24 03:36:51 +00:00
feat(search): add Jellyfin-backed search
This commit is contained in:
@@ -113,6 +113,7 @@ fun SearchFullScreen(
|
|||||||
onResultClick = viewModel::onSearchResultSelected,
|
onResultClick = viewModel::onSearchResultSelected,
|
||||||
onGenreSelected = { genre ->
|
onGenreSelected = { genre ->
|
||||||
selectedGenreName = genre.name.takeIf { it != selectedGenreName }
|
selectedGenreName = genre.name.takeIf { it != selectedGenreName }
|
||||||
|
viewModel.setSelectedGenre(selectedGenreName)
|
||||||
},
|
},
|
||||||
modifier = modifier.then(sharedBoundsModifier)
|
modifier = modifier.then(sharedBoundsModifier)
|
||||||
)
|
)
|
||||||
@@ -158,22 +159,45 @@ private fun SearchFullScreenContent(
|
|||||||
selectedGenreName = selectedGenreName,
|
selectedGenreName = selectedGenreName,
|
||||||
onGenreSelected = onGenreSelected
|
onGenreSelected = onGenreSelected
|
||||||
)
|
)
|
||||||
|
if (selectedGenreName != null) {
|
||||||
|
Spacer(modifier = Modifier.height(30.dp))
|
||||||
|
SectionTitle(text = "Search Results")
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
SearchResults(
|
||||||
|
searchResults = searchResults,
|
||||||
|
onResultClick = onResultClick
|
||||||
|
)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
SectionTitle(text = "Search Results")
|
SectionTitle(text = "Search Results")
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
if (searchResults.isEmpty()) {
|
SearchResults(
|
||||||
SearchMessage(
|
searchResults = searchResults,
|
||||||
title = "No matches",
|
|
||||||
body = "Try a different title or browse your libraries."
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
SearchResultsGrid(
|
|
||||||
results = searchResults,
|
|
||||||
onResultClick = onResultClick
|
onResultClick = onResultClick
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SearchResults(
|
||||||
|
searchResults: List<SearchResult>,
|
||||||
|
onResultClick: (SearchResult) -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
if (searchResults.isEmpty()) {
|
||||||
|
SearchMessage(
|
||||||
|
title = "No matches",
|
||||||
|
body = "Try a different title or browse your libraries.",
|
||||||
|
modifier = modifier
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
SearchResultsGrid(
|
||||||
|
results = searchResults,
|
||||||
|
onResultClick = onResultClick,
|
||||||
|
modifier = modifier
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
package hu.bbara.purefin.ui.screen.home.components.search
|
package hu.bbara.purefin.ui.screen.home.components.search
|
||||||
|
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@@ -17,8 +21,8 @@ import androidx.compose.ui.layout.ContentScale
|
|||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import hu.bbara.purefin.model.MediaKind
|
|
||||||
import hu.bbara.purefin.feature.search.SearchResult
|
import hu.bbara.purefin.feature.search.SearchResult
|
||||||
|
import hu.bbara.purefin.model.MediaKind
|
||||||
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
|
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -29,43 +33,50 @@ internal fun SearchResultCard(
|
|||||||
) {
|
) {
|
||||||
val scheme = MaterialTheme.colorScheme
|
val scheme = MaterialTheme.colorScheme
|
||||||
|
|
||||||
Surface(
|
Card(
|
||||||
shape = RoundedCornerShape(22.dp),
|
onClick = onClick,
|
||||||
color = scheme.surfaceContainer,
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = scheme.surfaceContainer),
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clip(RoundedCornerShape(22.dp))
|
|
||||||
.clickable(onClick = onClick)
|
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(modifier = Modifier.fillMaxWidth()) {
|
||||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
Box(
|
||||||
modifier = Modifier.padding(12.dp)
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.aspectRatio(16f / 10f)
|
||||||
|
.clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp))
|
||||||
|
.background(scheme.surface)
|
||||||
) {
|
) {
|
||||||
PurefinAsyncImage(
|
PurefinAsyncImage(
|
||||||
model = item.posterUrl,
|
model = item.posterUrl,
|
||||||
contentDescription = item.title,
|
contentDescription = item.title,
|
||||||
contentScale = ContentScale.Crop,
|
contentScale = ContentScale.Crop,
|
||||||
modifier = Modifier
|
modifier = Modifier.fillMaxSize()
|
||||||
.fillMaxWidth()
|
|
||||||
.height(180.dp)
|
|
||||||
.clip(RoundedCornerShape(18.dp))
|
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(10.dp))
|
||||||
|
Column(modifier = Modifier.padding(12.dp)) {
|
||||||
Text(
|
Text(
|
||||||
text = item.title,
|
text = item.title,
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
maxLines = 2,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis
|
overflow = TextOverflow.Ellipsis
|
||||||
)
|
)
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
Text(
|
Text(
|
||||||
text = when (item.type) {
|
text = when (item.type) {
|
||||||
MediaKind.MOVIE -> "Movie"
|
MediaKind.MOVIE -> "Movie"
|
||||||
MediaKind.SERIES -> "Series"
|
MediaKind.SERIES -> "Series"
|
||||||
else -> "Title"
|
else -> "Title"
|
||||||
},
|
},
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = scheme.onSurfaceVariant
|
color = scheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
12
core/src/main/java/hu/bbara/purefin/data/SearchManager.kt
Normal file
12
core/src/main/java/hu/bbara/purefin/data/SearchManager.kt
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
package hu.bbara.purefin.data
|
||||||
|
|
||||||
|
import hu.bbara.purefin.feature.search.SearchResult
|
||||||
|
import hu.bbara.purefin.model.Genre
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
|
interface SearchManager {
|
||||||
|
val searchResult : StateFlow<List<SearchResult>>
|
||||||
|
val genres: StateFlow<Set<Genre>>
|
||||||
|
fun setGenres(genres : Set<String>)
|
||||||
|
fun setSearchTerm(searchTerm: String)
|
||||||
|
}
|
||||||
@@ -1,65 +1,31 @@
|
|||||||
package hu.bbara.purefin.feature.search
|
package hu.bbara.purefin.feature.search
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import hu.bbara.purefin.data.LocalMediaRepository
|
import hu.bbara.purefin.data.SearchManager
|
||||||
import hu.bbara.purefin.data.UserSessionRepository
|
|
||||||
import hu.bbara.purefin.image.ArtworkKind
|
|
||||||
import hu.bbara.purefin.image.ImageUrlBuilder
|
|
||||||
import hu.bbara.purefin.model.MediaKind
|
import hu.bbara.purefin.model.MediaKind
|
||||||
import hu.bbara.purefin.navigation.MovieDto
|
import hu.bbara.purefin.navigation.MovieDto
|
||||||
import hu.bbara.purefin.navigation.NavigationManager
|
import hu.bbara.purefin.navigation.NavigationManager
|
||||||
import hu.bbara.purefin.navigation.Route
|
import hu.bbara.purefin.navigation.Route
|
||||||
import hu.bbara.purefin.navigation.SeriesDto
|
import hu.bbara.purefin.navigation.SeriesDto
|
||||||
import kotlinx.coroutines.FlowPreview
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
|
||||||
import kotlinx.coroutines.flow.combine
|
|
||||||
import kotlinx.coroutines.flow.debounce
|
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
|
||||||
import kotlinx.coroutines.flow.first
|
|
||||||
import kotlinx.coroutines.flow.launchIn
|
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
@OptIn(FlowPreview::class)
|
|
||||||
class SearchViewModel @Inject constructor(
|
class SearchViewModel @Inject constructor(
|
||||||
private val mediaCatalogReader: LocalMediaRepository,
|
private val searchManager: SearchManager,
|
||||||
private val userSessionRepository: UserSessionRepository,
|
|
||||||
private val navigationManager: NavigationManager,
|
private val navigationManager: NavigationManager,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
val genres = mediaCatalogReader.genres
|
val genres = searchManager.genres
|
||||||
|
val searchResult = searchManager.searchResult
|
||||||
private val _searchResult = MutableStateFlow<List<SearchResult>>(emptyList())
|
|
||||||
val searchResult = _searchResult.asStateFlow()
|
|
||||||
|
|
||||||
private val query = MutableStateFlow("")
|
|
||||||
|
|
||||||
init {
|
|
||||||
combine(
|
|
||||||
query.debounce(300).distinctUntilChanged(),
|
|
||||||
mediaCatalogReader.movies,
|
|
||||||
mediaCatalogReader.series
|
|
||||||
) { currentQuery, movies, series ->
|
|
||||||
val filteredMovies = movies.filter {
|
|
||||||
it.value.title.contains(currentQuery, ignoreCase = true)
|
|
||||||
}
|
|
||||||
val filteredSeries = series.filter {
|
|
||||||
it.value.name.contains(currentQuery, ignoreCase = true)
|
|
||||||
}
|
|
||||||
_searchResult.value = filteredMovies.values.map {
|
|
||||||
SearchResult.create(it, createImageUrl(it.id))
|
|
||||||
} + filteredSeries.values.map {
|
|
||||||
SearchResult.create(it, createImageUrl(it.id))
|
|
||||||
}
|
|
||||||
}.launchIn(viewModelScope)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun search(query: String) {
|
fun search(query: String) {
|
||||||
this.query.value = query
|
searchManager.setSearchTerm(query)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setSelectedGenre(genreName: String?) {
|
||||||
|
searchManager.setGenres(genreName?.let { setOf(it) } ?: emptySet())
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onSearchResultSelected(searchResult: SearchResult) {
|
fun onSearchResultSelected(searchResult: SearchResult) {
|
||||||
@@ -90,8 +56,4 @@ class SearchViewModel @Inject constructor(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun createImageUrl(id: UUID) : String {
|
|
||||||
return ImageUrlBuilder.toImageUrl(userSessionRepository.serverUrl.first(), id,
|
|
||||||
ArtworkKind.PRIMARY)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package hu.bbara.purefin.data.jellyfin
|
||||||
|
|
||||||
|
import hu.bbara.purefin.data.SearchManager
|
||||||
|
import hu.bbara.purefin.data.UserSessionRepository
|
||||||
|
import hu.bbara.purefin.data.jellyfin.client.JellyfinApiClient
|
||||||
|
import hu.bbara.purefin.feature.search.SearchResult
|
||||||
|
import hu.bbara.purefin.image.ArtworkKind
|
||||||
|
import hu.bbara.purefin.image.ImageUrlBuilder
|
||||||
|
import hu.bbara.purefin.model.Genre
|
||||||
|
import hu.bbara.purefin.model.MediaKind
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||||
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class SearchManagerImpl @Inject constructor(
|
||||||
|
private val jellyfinApiClient: JellyfinApiClient,
|
||||||
|
private val userSessionRepository: UserSessionRepository
|
||||||
|
) : SearchManager {
|
||||||
|
|
||||||
|
private suspend fun getServerUrl(): String {
|
||||||
|
return userSessionRepository.serverUrl.first()
|
||||||
|
}
|
||||||
|
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
private val searchTerm = MutableStateFlow("")
|
||||||
|
private val selectedGenres = MutableStateFlow<Set<String>>(emptySet())
|
||||||
|
private val _genres = MutableStateFlow<Set<Genre>>(emptySet())
|
||||||
|
|
||||||
|
override val searchResult: StateFlow<List<SearchResult>> =
|
||||||
|
combine(searchTerm, selectedGenres) { searchTerm, genres ->
|
||||||
|
search(searchTerm, genres)
|
||||||
|
}.stateIn(scope, SharingStarted.Eagerly, emptyList())
|
||||||
|
|
||||||
|
override val genres: StateFlow<Set<Genre>> = _genres.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
scope.launch {
|
||||||
|
_genres.value = jellyfinApiClient.getGenres()
|
||||||
|
.mapNotNull { it.name }
|
||||||
|
.map { Genre(name = it) }
|
||||||
|
.toSet()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setGenres(genres: Set<String>) {
|
||||||
|
selectedGenres.value = genres
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setSearchTerm(searchTerm: String) {
|
||||||
|
this.searchTerm.value = searchTerm
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun search(searchTerm: String, genres: Set<String>): List<SearchResult> {
|
||||||
|
if (searchTerm.isNotEmpty()) {
|
||||||
|
val searchBySearchTerm = jellyfinApiClient.searchBySearchTerm(searchTerm)
|
||||||
|
return searchBySearchTerm.map { it.toSearchResult() }
|
||||||
|
}
|
||||||
|
if (genres.isNotEmpty()) {
|
||||||
|
val searchByGenre = jellyfinApiClient.searchByGenre(genres)
|
||||||
|
return searchByGenre.map { it.toSearchResult() }
|
||||||
|
}
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun BaseItemDto.toSearchResult(): SearchResult {
|
||||||
|
return SearchResult(
|
||||||
|
id = id,
|
||||||
|
title = name!!,
|
||||||
|
posterUrl = ImageUrlBuilder.toImageUrl(
|
||||||
|
url = getServerUrl(),
|
||||||
|
itemId = id,
|
||||||
|
artworkKind = ArtworkKind.PRIMARY
|
||||||
|
),
|
||||||
|
type = when (type) {
|
||||||
|
BaseItemKind.MOVIE -> MediaKind.MOVIE
|
||||||
|
BaseItemKind.SERIES -> MediaKind.SERIES
|
||||||
|
else -> throw UnsupportedOperationException("Unsupported media type: $type")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
@@ -116,13 +116,14 @@ class JellyfinApiClient @Inject constructor(
|
|||||||
userId = getUserId(),
|
userId = getUserId(),
|
||||||
includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES),
|
includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES),
|
||||||
searchTerm = searchTerm,
|
searchTerm = searchTerm,
|
||||||
|
recursive = true
|
||||||
)
|
)
|
||||||
Log.d("searchBySearchTerm", response.content.toString())
|
Log.d("searchBySearchTerm", response.content.toString())
|
||||||
response.content.items
|
response.content.items
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun searchByGenre(genres: List<String>): List<BaseItemDto> = withContext(Dispatchers.IO) {
|
suspend fun searchByGenre(genres: Set<String>): List<BaseItemDto> = withContext(Dispatchers.IO) {
|
||||||
logApiFailure("searchMovie") {
|
logApiFailure("searchMovie") {
|
||||||
if (!ensureConfigured()) {
|
if (!ensureConfigured()) {
|
||||||
return@logApiFailure emptyList()
|
return@logApiFailure emptyList()
|
||||||
@@ -130,7 +131,8 @@ class JellyfinApiClient @Inject constructor(
|
|||||||
val response = api.itemsApi.getItems(
|
val response = api.itemsApi.getItems(
|
||||||
userId = getUserId(),
|
userId = getUserId(),
|
||||||
includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES),
|
includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES),
|
||||||
genres = genres
|
genres = genres,
|
||||||
|
recursive = true
|
||||||
)
|
)
|
||||||
Log.d("searchByGenre", response.content.toString())
|
Log.d("searchByGenre", response.content.toString())
|
||||||
response.content.items
|
response.content.items
|
||||||
|
|||||||
@@ -8,9 +8,11 @@ import hu.bbara.purefin.Offline
|
|||||||
import hu.bbara.purefin.Online
|
import hu.bbara.purefin.Online
|
||||||
import hu.bbara.purefin.data.HomeRepository
|
import hu.bbara.purefin.data.HomeRepository
|
||||||
import hu.bbara.purefin.data.LocalMediaRepository
|
import hu.bbara.purefin.data.LocalMediaRepository
|
||||||
|
import hu.bbara.purefin.data.SearchManager
|
||||||
import hu.bbara.purefin.data.catalog.InMemoryAppContentRepository
|
import hu.bbara.purefin.data.catalog.InMemoryAppContentRepository
|
||||||
import hu.bbara.purefin.data.catalog.InMemoryLocalMediaRepository
|
import hu.bbara.purefin.data.catalog.InMemoryLocalMediaRepository
|
||||||
import hu.bbara.purefin.data.catalog.OfflineLocalMediaRepository
|
import hu.bbara.purefin.data.catalog.OfflineLocalMediaRepository
|
||||||
|
import hu.bbara.purefin.data.jellyfin.SearchManagerImpl
|
||||||
|
|
||||||
@Module
|
@Module
|
||||||
@InstallIn(SingletonComponent::class)
|
@InstallIn(SingletonComponent::class)
|
||||||
@@ -26,4 +28,7 @@ abstract class MediaRepositoryModule {
|
|||||||
|
|
||||||
@Binds
|
@Binds
|
||||||
abstract fun bindHomeRepository(impl: InMemoryAppContentRepository): HomeRepository
|
abstract fun bindHomeRepository(impl: InMemoryAppContentRepository): HomeRepository
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
abstract fun bindSearchManager(impl: SearchManagerImpl): SearchManager
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user