Refactor search.

Introduce custom DataSource.
This commit is contained in:
Thomas Andres Gomez 2022-10-15 23:30:11 +02:00
parent 978826e6a6
commit d3d7f89e22
31 changed files with 1101 additions and 385 deletions

View file

@ -30,6 +30,7 @@ abstract class BibLibDatabase : RoomDatabase() {
abstract fun languageDao(): LanguageDao
abstract fun seriesDao(): SeriesDao
abstract fun crossRefDao(): CrossRefDao
abstract fun searchDao(): SearchDao
companion object {
const val VERSION = 1

View file

@ -5,10 +5,14 @@ import androidx.room.Entity
import com.pixelized.biblib.database.data.AuthorDbo
import com.pixelized.biblib.database.data.BookDbo
@Entity(primaryKeys = [BookDbo.ID, AuthorDbo.ID])
@Entity(tableName = BookAuthorCrossRef.TABLE, primaryKeys = [BookDbo.ID, AuthorDbo.ID])
data class BookAuthorCrossRef(
@ColumnInfo(name = BookDbo.ID)
val bookId: Int,
@ColumnInfo(name = AuthorDbo.ID, index = true)
val authorId: Int,
)
) {
companion object {
const val TABLE = "BookAuthorCrossRef"
}
}

View file

@ -5,10 +5,14 @@ import androidx.room.Entity
import com.pixelized.biblib.database.data.BookDbo
import com.pixelized.biblib.database.data.GenreDbo
@Entity(primaryKeys = [BookDbo.ID, GenreDbo.ID])
@Entity(tableName = BookGenreCrossRef.TABLE, primaryKeys = [BookDbo.ID, GenreDbo.ID])
data class BookGenreCrossRef(
@ColumnInfo(name = BookDbo.ID)
val bookId: Int,
@ColumnInfo(name = GenreDbo.ID, index = true)
val genreId: Int,
)
) {
companion object {
const val TABLE = "BookGenreCrossRef"
}
}

View file

@ -0,0 +1,34 @@
package com.pixelized.biblib.database.dao
import androidx.room.Dao
import androidx.room.RawQuery
import androidx.room.Transaction
import androidx.sqlite.db.SupportSQLiteQuery
import com.pixelized.biblib.database.data.AuthorDbo
import com.pixelized.biblib.database.data.GenreDbo
import com.pixelized.biblib.database.data.LanguageDbo
import com.pixelized.biblib.database.data.SeriesDbo
import com.pixelized.biblib.database.relation.BookRelation
@Dao
interface SearchDao {
@Transaction
@RawQuery
fun getBooks(query: SupportSQLiteQuery): List<BookRelation>
@Transaction
@RawQuery
fun getAuthors(query: SupportSQLiteQuery): List<AuthorDbo>
@Transaction
@RawQuery
fun getSeries(query: SupportSQLiteQuery): List<SeriesDbo>
@Transaction
@RawQuery
fun getGenres(query: SupportSQLiteQuery): List<GenreDbo>
@Transaction
@RawQuery
fun getLanguages(query: SupportSQLiteQuery): List<LanguageDbo>
}

View file

@ -2,6 +2,7 @@ package com.pixelized.biblib.module
import android.app.Application
import android.content.SharedPreferences
import androidx.room.PrimaryKey
import com.google.gson.Gson
import com.pixelized.biblib.database.BibLibDatabase
import com.pixelized.biblib.network.client.IBibLibClient
@ -13,6 +14,8 @@ import com.pixelized.biblib.repository.credential.CredentialRepository
import com.pixelized.biblib.repository.credential.ICredentialRepository
import com.pixelized.biblib.repository.googleSignIn.GoogleSingInRepository
import com.pixelized.biblib.repository.googleSignIn.IGoogleSingInRepository
import com.pixelized.biblib.repository.search.ISearchRepository
import com.pixelized.biblib.repository.search.SearchRepository
import com.pixelized.biblib.repository.user.IUserRepository
import com.pixelized.biblib.repository.user.UserRepository
import dagger.Module
@ -68,6 +71,16 @@ class RepositoryModule {
)
}
@Provides
@Singleton
fun provideSearchRepository(
database: BibLibDatabase,
): ISearchRepository {
return SearchRepository(
database = database,
)
}
@Provides
@Singleton
fun provideUserRepository(

View file

@ -0,0 +1,40 @@
package com.pixelized.biblib.repository.search
import com.pixelized.biblib.model.book.*
interface ISearchRepository {
fun searchBooks(
search: String?,
authorId: Int?,
seriesId: Int?,
genreId: Int?,
languageId: Int?,
limit: Int,
offset: Int,
): List<Book>
fun searchAuthor(
search: String?,
limit: Int,
offset: Int,
): List<Author>
fun searchGenre(
search: String?,
limit: Int,
offset: Int,
): List<Genre>
fun searchSeries(
search: String?,
limit: Int,
offset: Int,
): List<Series>
fun searchLanguage(
search: String?,
limit: Int,
offset: Int,
): List<Language>
}

View file

@ -0,0 +1,165 @@
package com.pixelized.biblib.repository.search
import androidx.sqlite.db.SimpleSQLiteQuery
import com.pixelized.biblib.database.BibLibDatabase
import com.pixelized.biblib.database.crossref.BookAuthorCrossRef
import com.pixelized.biblib.database.crossref.BookGenreCrossRef
import com.pixelized.biblib.database.data.*
import com.pixelized.biblib.database.relation.BookRelation
import com.pixelized.biblib.model.book.*
import javax.inject.Inject
class SearchRepository @Inject constructor(
private val database: BibLibDatabase
) : ISearchRepository {
override fun searchBooks(
search: String?,
authorId: Int?,
seriesId: Int?,
genreId: Int?,
languageId: Int?,
limit: Int,
offset: Int
): List<Book> {
// build an argument list for the SimpleSQLQuery.
val args = mutableListOf<Any>()
// build the core of the SQL query.
var query = "SELECT ${BookDbo.TABLE}.* FROM ${BookDbo.TABLE}"
// add where arguments.
query += args.where(argument = search) {
BookDbo.run { "$TABLE.$TITLE LIKE '%'||?||'%'" }
}
query += args.where(argument = authorId) {
"${BookDbo.ID} IN (SELECT ${BookDbo.ID} FROM ${BookAuthorCrossRef.TABLE} WHERE ${AuthorDbo.ID} LIKE ?)"
}
query += args.where(argument = genreId) {
"${BookDbo.ID} IN (SELECT ${BookDbo.ID} FROM ${BookGenreCrossRef.TABLE} WHERE ${GenreDbo.ID} LIKE ?)"
}
query += args.where(argument = seriesId) {
BookDbo.run { "$TABLE.$SERIES_ID LIKE ?" }
}
query += args.where(argument = languageId) {
BookDbo.run { "$TABLE.$LANGUAGE_ID LIKE ?" }
}
// Limit and Offset the query.
query += " LIMIT $limit OFFSET $offset;"
// compute the query
val liteQuery = SimpleSQLiteQuery(query, args.toTypedArray())
val result = database.searchDao().getBooks(liteQuery)
return result.map { it.toBook() }
}
override fun searchAuthor(search: String?, limit: Int, offset: Int): List<Author> {
// build an argument list for the SimpleSQLQuery.
val args = mutableListOf<Any>()
// build the SQL query.
val query = AuthorDbo.run {
"SELECT $TABLE.* FROM $TABLE" +
args.where(argument = search) { "$TABLE.$NAME LIKE '%'||?||'%'" } +
" LIMIT $limit OFFSET $offset;"
}
// compute the query
val liteQuery = SimpleSQLiteQuery(query, args.toTypedArray())
val result = database.searchDao().getAuthors(liteQuery)
return result.map { it.toAuthor() }
}
override fun searchGenre(search: String?, limit: Int, offset: Int): List<Genre> {
// build an argument list for the SimpleSQLQuery.
val args = mutableListOf<Any>()
// build the SQL query.
val query = GenreDbo.run {
"SELECT $TABLE.* FROM $TABLE" +
args.where(argument = search) { "$TABLE.$NAME LIKE '%'||?||'%'" } +
" LIMIT $limit OFFSET $offset;"
}
// compute the query
val liteQuery = SimpleSQLiteQuery(query, args.toTypedArray())
val result = database.searchDao().getGenres(liteQuery)
return result.map { it.toGenre() }
}
override fun searchSeries(search: String?, limit: Int, offset: Int): List<Series> {
// build an argument list for the SimpleSQLQuery.
val args = mutableListOf<Any>()
// build the SQL query.
val query = SeriesDbo.run {
"SELECT $TABLE.* FROM $TABLE" +
args.where(argument = search) { "$TABLE.$NAME LIKE '%'||?||'%'" } +
" LIMIT $limit OFFSET $offset;"
}
// compute the query
val liteQuery = SimpleSQLiteQuery(query, args.toTypedArray())
val result = database.searchDao().getSeries(liteQuery)
return result.map { it.toSeries() }
}
override fun searchLanguage(search: String?, limit: Int, offset: Int): List<Language> {
// build an argument list for the SimpleSQLQuery.
val args = mutableListOf<Any>()
// build the SQL query.
val query = LanguageDbo.run {
"SELECT $TABLE.* FROM $TABLE" +
args.where(argument = search) { "$TABLE.$NAME LIKE '%'||?||'%'" } +
" LIMIT $limit OFFSET $offset;"
}
// compute the query
val liteQuery = SimpleSQLiteQuery(query, args.toTypedArray())
val result = database.searchDao().getLanguages(liteQuery)
return result.map { it.toLanguage() }
}
}
private fun MutableList<Any>.where(
argument: Any?,
block: () -> String,
): String {
return if (argument != null) {
val prefix = if (isEmpty()) "WHERE" else "AND"
add(argument)
" $prefix ${block()}"
} else {
""
}
}
// TODO Duplicate
private fun BookRelation.toBook(): Book = Book(
id = book.id,
title = book.title,
sort = book.sort,
author = authors.map { it.toAuthor() },
haveCover = book.haveCover,
releaseDate = book.releaseDate,
language = language?.toLanguage(),
rating = book.rating,
genre = genres?.map { it.toGenre() },
series = series?.toSeries(),
synopsis = book.synopsis,
isNew = book.isNew,
)
private fun AuthorDbo.toAuthor() = Author(
id = id,
name = name,
sort = sort,
)
private fun LanguageDbo.toLanguage() = Language(
id = id,
code = code,
)
private fun GenreDbo.toGenre() = Genre(
id = id,
name = name,
)
private fun SeriesDbo.toSeries() = Series(
id = id,
name = name,
sort = sort,
index = index,
)

View file

@ -13,7 +13,6 @@ inline fun <reified T> rememberSavableMutableTransitionState(
): MutableTransitionState<T> {
return rememberSaveable(saver = mutableTransitionStateSaver()) {
MutableTransitionState(initialState).apply { this.targetState = targetState }
}
}

View file

@ -1,7 +1,6 @@
package com.pixelized.biblib.ui.scaffold
import androidx.activity.compose.BackHandler
import androidx.annotation.StringRes
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.ModalBottomSheetLayout
@ -13,15 +12,12 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.hilt.navigation.compose.hiltViewModel
import com.pixelized.biblib.R
import com.pixelized.biblib.ui.screen.home.page.search.CategorySearchPage
import com.pixelized.biblib.ui.screen.home.page.search.SearchViewModel
import com.pixelized.biblib.ui.screen.home.page.search.FilterSearchPage
import com.pixelized.biblib.ui.screen.home.page.search.viewModel.BookSearchViewModel
import com.pixelized.biblib.ui.theme.color.ShadowPalette
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import java.io.Serializable
val LocalSearchViewModel = staticCompositionLocalOf<SearchViewModel> {
val LocalBookSearchViewModel = staticCompositionLocalOf<BookSearchViewModel> {
error("SearchViewModel is not ready yet")
}
val LocalCategorySearchBottomSheetState = staticCompositionLocalOf<SearchBottomSheetState> {
@ -32,11 +28,13 @@ val LocalCategorySearchBottomSheetState = staticCompositionLocalOf<SearchBottomS
@Composable
fun CategorySearchBottomSheet(
state: SearchBottomSheetState = rememberSearchBottomSheetState(),
searchViewModel: SearchViewModel = hiltViewModel(),
bookSearchViewModel: BookSearchViewModel = hiltViewModel(),
content: @Composable () -> Unit,
) {
val scope = rememberCoroutineScope()
CompositionLocalProvider(
LocalSearchViewModel provides searchViewModel,
LocalBookSearchViewModel provides bookSearchViewModel,
LocalCategorySearchBottomSheetState provides state,
) {
ModalBottomSheetLayout(
@ -44,27 +42,17 @@ fun CategorySearchBottomSheet(
scrimColor = ShadowPalette.scrim,
sheetState = state.bottomSheetState,
sheetContent = {
CategorySearchPage(
searchViewModel = searchViewModel,
FilterSearchPage(
focusRequester = state.focusRequester,
filter = state.filter,
onClose = {
when(state.filter) {
is SearchFilter.Author -> searchViewModel.authors.clear()
is SearchFilter.Series -> searchViewModel.series.clear()
is SearchFilter.Genre -> searchViewModel.genre.clear()
is SearchFilter.Language -> searchViewModel.language.clear()
null -> Unit
}
state.collapse()
}
)
},
content = content,
)
BackHandler(state.bottomSheetState.isVisible) {
state.collapse()
scope.launch {
state.collapse()
}
}
}
}
@ -72,31 +60,27 @@ fun CategorySearchBottomSheet(
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun rememberSearchBottomSheetState(
scope: CoroutineScope = rememberCoroutineScope(),
focusRequester: FocusRequester = remember { FocusRequester() },
bottomSheetState: ModalBottomSheetState = rememberModalBottomSheetState(
initialValue = Hidden,
skipHalfExpanded = true,
),
): SearchBottomSheetState {
val filter = rememberSaveable(scope, bottomSheetState) {
val filter = rememberSaveable(bottomSheetState) {
mutableStateOf<SearchFilter?>(null)
}
val focusRequester = remember {
FocusRequester()
return remember(bottomSheetState) {
SearchBottomSheetState(
bottomSheetState = bottomSheetState,
focusRequester = focusRequester,
filter = filter,
)
}
val controller = SearchBottomSheetState(
scope = scope,
bottomSheetState = bottomSheetState,
focusRequester = focusRequester,
filter = filter,
)
return remember(scope, bottomSheetState) { controller }
}
@OptIn(ExperimentalMaterialApi::class)
@Stable
class SearchBottomSheetState constructor(
private val scope: CoroutineScope,
val bottomSheetState: ModalBottomSheetState,
val focusRequester: FocusRequester,
filter: MutableState<SearchFilter?>,
@ -104,56 +88,22 @@ class SearchBottomSheetState constructor(
var filter: SearchFilter? by filter
private set
fun expandSearch(filter: SearchFilter?) {
suspend fun expandSearch(filter: SearchFilter?) {
this.filter = filter
scope.launch {
bottomSheetState.show()
focusRequester.requestFocus()
}
bottomSheetState.show()
focusRequester.requestFocus()
}
fun collapse() {
scope.launch {
bottomSheetState.hide()
}
suspend fun collapse() {
bottomSheetState.hide()
}
}
sealed class SearchFilter(
@StringRes val label: Int,
val value: String?,
) : Serializable {
val isSelected: Boolean get() = value != null
class Author(
value: String? = null,
) : SearchFilter(
label = R.string.search_filter_author,
value = value,
)
class Series(
value: String? = null,
): SearchFilter(
label = R.string.search_filter_serie,
value = value,
)
class Genre(
value: String? = null,
) : SearchFilter(
label = R.string.search_filter_genre,
value = value,
)
class Language(
value: String? = null,
) : SearchFilter(
label = R.string.search_filter_language,
value = value,
)
companion object {
val all = listOf(Author(), Genre(), Language())
}
@Stable
@Immutable
enum class SearchFilter {
Author,
Series,
Genre,
Language;
}

View file

@ -13,12 +13,11 @@ import androidx.compose.ui.platform.LocalContext
import androidx.hilt.navigation.compose.hiltViewModel
import com.pixelized.biblib.R
import com.pixelized.biblib.ui.composable.StateUio
import com.pixelized.biblib.ui.screen.home.detail.BookDetailUio
import com.pixelized.biblib.ui.screen.home.detail.BookDetailViewModel
import com.pixelized.biblib.ui.screen.home.detail.DetailScreen
import com.pixelized.biblib.ui.screen.home.detail.BookDetailUio
import com.pixelized.biblib.ui.theme.color.ShadowPalette
import com.pixelized.biblib.utils.extention.showToast
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
val LocalDetailBottomSheetState = staticCompositionLocalOf<DetailBottomSheetState> {
@ -31,6 +30,8 @@ fun DetailBottomSheet(
bottomDetailState: DetailBottomSheetState = rememberDetailBottomSheetState(),
content: @Composable () -> Unit,
) {
val scope = rememberCoroutineScope()
CompositionLocalProvider(
LocalDetailBottomSheetState provides bottomDetailState
) {
@ -47,7 +48,9 @@ fun DetailBottomSheet(
)
BackHandler(bottomDetailState.bottomSheetState.isVisible) {
bottomDetailState.collapse()
scope.launch {
bottomDetailState.collapse()
}
}
}
}
@ -56,31 +59,28 @@ fun DetailBottomSheet(
@Composable
fun rememberDetailBottomSheetState(
viewModel: BookDetailViewModel = hiltViewModel(),
scope: CoroutineScope = rememberCoroutineScope(),
bottomSheetState: ModalBottomSheetState = rememberModalBottomSheetState(
initialValue = Hidden,
skipHalfExpanded = true,
),
): DetailBottomSheetState {
val context: Context = LocalContext.current
val detail = rememberSaveable(scope, viewModel, bottomSheetState) {
val detail = rememberSaveable(viewModel, bottomSheetState) {
mutableStateOf<BookDetailUio?>(null)
}
val controller = DetailBottomSheetState(
context = context,
viewModel = viewModel,
scope = scope,
bottomSheetState = bottomSheetState,
bookDetail = detail,
)
return remember(scope, viewModel, bottomSheetState) { controller }
return remember(viewModel, bottomSheetState) { controller }
}
@OptIn(ExperimentalMaterialApi::class)
@Stable
class DetailBottomSheetState constructor(
private val context: Context,
private val scope: CoroutineScope,
val viewModel: BookDetailViewModel,
val bottomSheetState: ModalBottomSheetState,
bookDetail: MutableState<BookDetailUio?>,
@ -88,25 +88,21 @@ class DetailBottomSheetState constructor(
var bookDetail: BookDetailUio? by bookDetail
private set
fun expandBookDetail(id: Int) {
scope.launch {
when (val book = viewModel.getDetail(id)) {
is StateUio.Failure -> {
val mes = book.exception.message ?: context.getString(R.string.error_generic)
context.showToast(message = mes)
}
is StateUio.Success -> {
bookDetail = book.value
bottomSheetState.show()
}
else -> Unit
suspend fun expandBookDetail(id: Int) {
when (val book = viewModel.getDetail(id)) {
is StateUio.Failure -> {
val mes = book.exception.message ?: context.getString(R.string.error_generic)
context.showToast(message = mes)
}
is StateUio.Success -> {
bookDetail = book.value
bottomSheetState.show()
}
else -> Unit
}
}
fun collapse() {
scope.launch {
bottomSheetState.hide()
}
suspend fun collapse() {
bottomSheetState.hide()
}
}

View file

@ -20,7 +20,6 @@ import com.pixelized.biblib.repository.book.updateBooks
import com.pixelized.biblib.repository.credential.ICredentialRepository
import com.pixelized.biblib.repository.googleSignIn.IGoogleSingInRepository
import com.pixelized.biblib.ui.composable.StateUio
import com.pixelized.biblib.utils.exception.BookFetchException
import com.pixelized.biblib.utils.exception.MissingGoogleTokenException
import com.pixelized.biblib.utils.exception.MissingTokenException
import dagger.hilt.android.lifecycle.HiltViewModel

View file

@ -24,7 +24,6 @@ import com.google.accompanist.pager.HorizontalPager
import com.google.accompanist.pager.PagerState
import com.google.accompanist.pager.rememberPagerState
import com.pixelized.biblib.R
import com.pixelized.biblib.ui.screen.home.page.search.Search
import com.pixelized.biblib.ui.scaffold.*
import com.pixelized.biblib.ui.scaffold.SearchScaffoldState.ContentState
import com.pixelized.biblib.ui.screen.home.common.connectivity.ConnectivityHeader
@ -33,6 +32,7 @@ import com.pixelized.biblib.ui.screen.home.page.Page
import com.pixelized.biblib.ui.screen.home.page.books.BooksPage
import com.pixelized.biblib.ui.screen.home.page.news.NewsPage
import com.pixelized.biblib.ui.screen.home.page.profile.ProfilePage
import com.pixelized.biblib.ui.screen.home.page.search.Search
import com.pixelized.biblib.ui.screen.home.page.search.SearchPage
import com.pixelized.biblib.ui.theme.BibLibTheme
import com.pixelized.biblib.utils.extention.bibLib
@ -62,8 +62,7 @@ fun HomeScreen(
modifier = Modifier.statusBarsPadding(),
state = searchScaffoldState,
topBar = {
val viewModel = LocalSearchViewModel.current
val search by viewModel.filterFlow.collectAsState(initial = "")
val viewModel = LocalBookSearchViewModel.current
Search(
state = searchScaffoldState,
avatar = accountViewModel.avatar,
@ -73,9 +72,9 @@ fun HomeScreen(
keyboard?.hide()
searchScaffoldState.collapse()
},
searchValue = search,
searchValue = viewModel.search ?: "",
onSearchValueChange = {
viewModel.filter(criteria = it)
viewModel.filterSearch(criteria = it)
},
onSearchTap = {
if (searchScaffoldState.content != ContentState.SEARCH || searchScaffoldState.isCollapsed()) {
@ -145,8 +144,12 @@ fun HomeScreen(
enabled = searchScaffoldState.isExpended || bottomSearchState.bottomSheetState.isVisible || bottomDetailState.bottomSheetState.isVisible
) {
when {
bottomSearchState.bottomSheetState.isVisible -> bottomSearchState.collapse()
bottomDetailState.bottomSheetState.isVisible -> bottomDetailState.collapse()
bottomSearchState.bottomSheetState.isVisible -> scope.launch {
bottomSearchState.collapse()
}
bottomDetailState.bottomSheetState.isVisible -> scope.launch {
bottomDetailState.collapse()
}
searchScaffoldState.isExpended -> searchScaffoldState.collapse()
}
}

View file

@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.hilt.navigation.compose.hiltViewModel
@ -19,6 +20,7 @@ import com.pixelized.biblib.ui.screen.home.common.item.SmallBookThumbnailUio
import com.pixelized.biblib.ui.theme.BibLibTheme
import com.pixelized.biblib.utils.extention.bibLib
import com.pixelized.biblib.utils.extention.navigationBarsHeight
import kotlinx.coroutines.launch
@Composable
@ -37,6 +39,7 @@ private fun BooksPageContent(
books: LazyPagingItems<SmallBookThumbnailUio>,
) {
val bottomDetailState = LocalDetailBottomSheetState.current
val scope = rememberCoroutineScope()
LazyColumn(
contentPadding = PaddingValues(
@ -53,7 +56,9 @@ private fun BooksPageContent(
modifier = Modifier.padding(horizontal = MaterialTheme.bibLib.dimen.thumbnail.padding),
thumbnail = thumbnail,
onClick = {
bottomDetailState.expandBookDetail(id = it.id)
scope.launch {
bottomDetailState.expandBookDetail(id = it.id)
}
},
)
}

View file

@ -9,6 +9,7 @@ import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.tooling.preview.Preview
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.paging.compose.LazyPagingItems
@ -20,6 +21,7 @@ import com.pixelized.biblib.ui.screen.home.common.preview.largeBookThumbnailPrev
import com.pixelized.biblib.ui.theme.BibLibTheme
import com.pixelized.biblib.utils.extention.bibLib
import com.pixelized.biblib.utils.extention.navigationBarsHeight
import kotlinx.coroutines.launch
@Composable
fun NewsPage(
@ -38,6 +40,7 @@ private fun NewsPageContent(
books: LazyPagingItems<LargeBookThumbnailUio>,
) {
val detailBottomSheetState: DetailBottomSheetState = LocalDetailBottomSheetState.current
val scope = rememberCoroutineScope()
LazyVerticalGrid(
columns = GridCells.Fixed(2),
@ -56,7 +59,9 @@ private fun NewsPageContent(
LargeBookThumbnail(
thumbnail = books[index],
onClick = {
detailBottomSheetState.expandBookDetail(id = it.id)
scope.launch {
detailBottomSheetState.expandBookDetail(id = it.id)
}
},
)
}

View file

@ -8,8 +8,7 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
@ -21,64 +20,79 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.paging.PagingData
import androidx.paging.compose.collectAsLazyPagingItems
import androidx.paging.compose.items
import com.pixelized.biblib.R
import com.pixelized.biblib.ui.scaffold.LocalBookSearchViewModel
import com.pixelized.biblib.ui.scaffold.LocalCategorySearchBottomSheetState
import com.pixelized.biblib.ui.scaffold.SearchFilter
import com.pixelized.biblib.ui.screen.home.page.search.viewModel.*
import com.pixelized.biblib.ui.theme.BibLibTheme
import com.pixelized.biblib.utils.extention.bibLib
import com.pixelized.biblib.utils.extention.default
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.launch
@Stable
@Immutable
data class FilterUio(
val id: Int,
val filter: String,
)
@Composable
fun CategorySearchPage(
searchViewModel: SearchViewModel = hiltViewModel(),
fun FilterSearchPage(
authorFilterViewModel: AuthorFilterViewModel = hiltViewModel(),
genreViewModel: GenreFilterViewModel = hiltViewModel(),
languageViewModel: LanguageFilterViewModel = hiltViewModel(),
seriesFilterViewModel: SeriesFilterViewModel = hiltViewModel(),
focusRequester: FocusRequester = FocusRequester(),
filter: SearchFilter?,
onClose: () -> Unit = default(),
) {
val bookSearchViewModel = LocalBookSearchViewModel.current
val bottomSearchState = LocalCategorySearchBottomSheetState.current
val scope = rememberCoroutineScope()
val filter = bottomSearchState.filter
val viewModel: IFilterViewModel? = remember(filter) {
when (filter) {
SearchFilter.Author -> authorFilterViewModel
SearchFilter.Series -> seriesFilterViewModel
SearchFilter.Genre -> genreViewModel
SearchFilter.Language -> languageViewModel
else -> null
}
}
CategorySearchPageContent(
focusRequester = focusRequester,
filter = filter,
onClose = onClose,
searchFlow = {
when (bottomSearchState.filter) {
is SearchFilter.Author -> searchViewModel.authors.filterFlow
is SearchFilter.Series -> searchViewModel.series.filterFlow
is SearchFilter.Genre -> searchViewModel.genre.filterFlow
is SearchFilter.Language -> searchViewModel.language.filterFlow
null -> emptyFlow()
search = { viewModel?.search },
paging = { viewModel?.paging ?: emptyFlow() },
onSearchUpdate = {
viewModel?.updateSearch(criteria = it)
},
onClose = {
when (filter) {
SearchFilter.Author -> bookSearchViewModel.filterAuthor(null)
SearchFilter.Series -> bookSearchViewModel.filterSeries(null)
SearchFilter.Genre -> bookSearchViewModel.filterGenre(null)
SearchFilter.Language -> bookSearchViewModel.filterLanguage(null)
else -> Unit
}
scope.launch {
bottomSearchState.collapse()
}
},
dataFlow = {
when (bottomSearchState.filter) {
is SearchFilter.Author -> searchViewModel.authors.dataFlow
is SearchFilter.Series -> searchViewModel.series.dataFlow
is SearchFilter.Genre -> searchViewModel.genre.dataFlow
is SearchFilter.Language -> searchViewModel.language.dataFlow
null -> emptyFlow()
onFilter = {
when (filter) {
SearchFilter.Author -> bookSearchViewModel.filterAuthor(it)
SearchFilter.Series -> bookSearchViewModel.filterSeries(it)
SearchFilter.Genre -> bookSearchViewModel.filterGenre(it)
SearchFilter.Language -> bookSearchViewModel.filterLanguage(it)
else -> Unit
}
},
onSearchChange = {
when (bottomSearchState.filter) {
is SearchFilter.Author -> searchViewModel.authors.filter(it)
is SearchFilter.Series -> searchViewModel.series.filter(it)
is SearchFilter.Genre -> searchViewModel.genre.filter(it)
is SearchFilter.Language -> searchViewModel.language.filter(it)
null -> Unit
scope.launch {
bottomSearchState.collapse()
}
},
onData = {
when (bottomSearchState.filter) {
is SearchFilter.Author -> searchViewModel.authors.confirm(it)
is SearchFilter.Series -> searchViewModel.series.confirm(it)
is SearchFilter.Genre -> searchViewModel.genre.confirm(it)
is SearchFilter.Language -> searchViewModel.language.confirm(it)
null -> Unit
}
bottomSearchState.collapse()
}
)
}
@ -87,13 +101,13 @@ fun CategorySearchPage(
fun CategorySearchPageContent(
focusRequester: FocusRequester = FocusRequester(),
filter: SearchFilter?,
searchFlow: () -> Flow<String>,
dataFlow: () -> Flow<PagingData<SearchViewModel.FilterUio>> = { emptyFlow() },
onSearchChange: (String) -> Unit = default<String>(),
onData: (SearchViewModel.FilterUio?) -> Unit = default<SearchViewModel.FilterUio?>(),
search: () -> String?,
paging: () -> Flow<PagingData<FilterUio>> = { emptyFlow() },
onSearchUpdate: (String) -> Unit = default<String>(),
onFilter: (FilterUio) -> Unit = default<FilterUio>(),
onClose: () -> Unit = default(),
) {
val data = dataFlow().collectAsLazyPagingItems()
val data = paging().collectAsLazyPagingItems()
Column(
modifier = Modifier
.imePadding()
@ -103,13 +117,17 @@ fun CategorySearchPageContent(
backgroundColor = MaterialTheme.colors.surface,
elevation = 0.dp,
title = {
filter?.let {
Text(
style = MaterialTheme.typography.h6,
color = MaterialTheme.colors.onSurface,
text = stringResource(id = it.label),
)
}
Text(
style = MaterialTheme.typography.h6,
color = MaterialTheme.colors.onSurface,
text = when (filter) {
SearchFilter.Author -> stringResource(id = R.string.search_filter_author)
SearchFilter.Genre -> stringResource(id = R.string.search_filter_genre)
SearchFilter.Series -> stringResource(id = R.string.search_filter_serie)
SearchFilter.Language -> stringResource(id = R.string.search_filter_language)
else -> ""
},
)
},
navigationIcon = {
IconButton(onClick = onClose) {
@ -130,14 +148,14 @@ fun CategorySearchPageContent(
text = "Rechercher"
)
},
value = searchFlow().collectAsState(initial = "").value,
value = search() ?: "",
singleLine = true,
colors = TextFieldDefaults.outlinedTextFieldColors(
focusedBorderColor = Color.Transparent,
unfocusedBorderColor = Color.Transparent,
disabledBorderColor = Color.Transparent,
),
onValueChange = onSearchChange
onValueChange = onSearchUpdate
)
LazyColumn(
modifier = Modifier.fillMaxSize()
@ -145,12 +163,12 @@ fun CategorySearchPageContent(
items(items = data, key = { it.id }) {
Text(
modifier = Modifier
.clickable { onData(it) }
.clickable { it?.let { onFilter(it) } }
.fillMaxWidth()
.padding(all = MaterialTheme.bibLib.dimen.dp16),
style = MaterialTheme.typography.body1,
color = MaterialTheme.colors.onSurface,
text = it?.label ?: ""
text = it?.filter ?: ""
)
}
}
@ -163,8 +181,8 @@ fun CategorySearchPageContent(
private fun CategorySearchPageContentPreview() {
BibLibTheme {
CategorySearchPageContent(
filter = SearchFilter.Author(),
searchFlow = { flow { "Asimov" } },
filter = SearchFilter.Author,
search = { "Asimov" },
)
}
}

View file

@ -10,24 +10,29 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.paging.PagingData
import androidx.paging.compose.collectAsLazyPagingItems
import androidx.paging.compose.items
import com.pixelized.biblib.R
import com.pixelized.biblib.ui.scaffold.LocalCategorySearchBottomSheetState
import com.pixelized.biblib.ui.scaffold.LocalDetailBottomSheetState
import com.pixelized.biblib.ui.scaffold.LocalSearchViewModel
import com.pixelized.biblib.ui.scaffold.LocalBookSearchViewModel
import com.pixelized.biblib.ui.scaffold.SearchFilter
import com.pixelized.biblib.ui.screen.home.common.item.MicroBookThumbnail
import com.pixelized.biblib.ui.screen.home.common.item.MicroBookThumbnailUio
import com.pixelized.biblib.ui.screen.home.page.search.item.SearchFilter
import com.pixelized.biblib.ui.screen.home.page.search.item.rememberSearchFilter
import com.pixelized.biblib.ui.screen.home.page.search.item.SearchFilterList
import com.pixelized.biblib.ui.screen.home.page.search.item.SearchFilterUio
import com.pixelized.biblib.ui.screen.home.page.search.item.searchFilterPreviewItems
import com.pixelized.biblib.ui.screen.home.page.search.viewModel.BookSearchViewModel
import com.pixelized.biblib.ui.theme.BibLibTheme
import com.pixelized.biblib.utils.extention.bibLib
import com.pixelized.biblib.utils.extention.default
@ -35,29 +40,34 @@ import com.pixelized.biblib.utils.extention.isLoading
import com.pixelized.biblib.utils.extention.navigationBarsHeight
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.launch
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun SearchPage(
searchViewModel: SearchViewModel = LocalSearchViewModel.current
bookSearchViewModel: BookSearchViewModel = LocalBookSearchViewModel.current,
) {
val keyboard = LocalSoftwareKeyboardController.current
val detail = LocalDetailBottomSheetState.current
val search = LocalCategorySearchBottomSheetState.current
val focus = LocalFocusManager.current
val filters = rememberSearchFilter()
val scope = rememberCoroutineScope()
SearchPageContent(
modifier = Modifier.fillMaxWidth(),
search = searchViewModel.search,
filters = filters,
search = bookSearchViewModel.paging,
filters = filters(bookSearchViewModel),
onFilter = {
search.expandSearch(it)
scope.launch {
search.expandSearch(it.id)
}
},
onDetail = {
focus.clearFocus(force = true)
keyboard?.hide()
detail.expandBookDetail(id = it.id)
scope.launch {
detail.expandBookDetail(id = it.id)
}
}
)
}
@ -65,10 +75,10 @@ fun SearchPage(
@Composable
private fun SearchPageContent(
modifier: Modifier = Modifier,
search: Flow<PagingData<MicroBookThumbnailUio>> = emptyFlow(),
filters: List<SearchFilter> = SearchFilter.all,
onFilter: (filter: SearchFilter) -> Unit = default<SearchFilter>(),
onDetail: (item: MicroBookThumbnailUio) -> Unit = default<MicroBookThumbnailUio>()
search: Flow<PagingData<MicroBookThumbnailUio>>,
filters: List<SearchFilterUio> = emptyList(),
onFilter: (filter: SearchFilterUio) -> Unit,
onDetail: (item: MicroBookThumbnailUio) -> Unit,
) {
val items = search.collectAsLazyPagingItems()
@ -81,9 +91,8 @@ private fun SearchPageContent(
space = MaterialTheme.bibLib.dimen.thumbnail.arrangement
),
) {
item(key = "Search Filter") {
SearchFilter(
modifier = Modifier.padding(horizontal = MaterialTheme.bibLib.dimen.dp16),
item(key = "Search Filters") {
SearchFilterList(
filters = filters,
onFilter = onFilter,
)
@ -121,11 +130,43 @@ private fun SearchLoader(
)
}
@Composable
private fun filters(
bookSearchViewModel: BookSearchViewModel,
) = listOf(
SearchFilterUio(
id = SearchFilter.Author,
label = stringResource(id = R.string.search_filter_author),
value = bookSearchViewModel.author?.filter,
),
SearchFilterUio(
id = SearchFilter.Genre,
label = stringResource(id = R.string.search_filter_genre),
value = bookSearchViewModel.genre?.filter,
),
SearchFilterUio(
id = SearchFilter.Series,
label = stringResource(id = R.string.search_filter_serie),
value = bookSearchViewModel.series?.filter,
),
SearchFilterUio(
id = SearchFilter.Language,
label = stringResource(id = R.string.search_filter_language),
value = bookSearchViewModel.language?.filter,
),
)
@Composable
@Preview(showBackground = true, uiMode = UI_MODE_NIGHT_NO)
@Preview(showBackground = true, uiMode = UI_MODE_NIGHT_YES)
private fun SearchPageContentPreview() {
BibLibTheme {
SearchPageContent()
SearchPageContent(
modifier = Modifier.fillMaxWidth(),
search = emptyFlow(),
filters = searchFilterPreviewItems(),
onFilter = default<SearchFilterUio>(),
onDetail = default<MicroBookThumbnailUio>(),
)
}
}

View file

@ -1,136 +0,0 @@
package com.pixelized.biblib.ui.screen.home.page.search
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.paging.*
import com.pixelized.biblib.repository.book.IBookRepository
import com.pixelized.biblib.utils.extention.toMicroThumbnailUio
import com.pixelized.biblib.utils.extention.toSmallThumbnailUio
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class SearchViewModel @Inject constructor(
bookRepository: IBookRepository,
) : ViewModel() {
val authors = CategoryFilterManager(
scope = viewModelScope,
source = bookRepository.getAuthorsSource()
.map { FilterUio(it.id, it.name) }
.asPagingSourceFactory(Dispatchers.IO),
sourceFilter = { data, filter -> data.label.contains(filter, ignoreCase = true) }
)
val series = CategoryFilterManager(
scope = viewModelScope,
source = bookRepository.getSeriesSource()
.map { FilterUio(it.id, it.name) }
.asPagingSourceFactory(Dispatchers.IO),
sourceFilter = { data, filter -> data.label.contains(filter, ignoreCase = true) }
)
val genre = CategoryFilterManager(
scope = viewModelScope,
source = bookRepository.getGenresSource()
.map { FilterUio(it.id, it.name) }
.asPagingSourceFactory(Dispatchers.IO),
sourceFilter = { data, filter -> data.label.contains(filter, ignoreCase = true) }
)
val language = CategoryFilterManager(
scope = viewModelScope,
source = bookRepository.getLanguagesSource()
.map { FilterUio(it.id, it.displayLanguage) }
.asPagingSourceFactory(Dispatchers.IO),
sourceFilter = { data, filter -> data.label.contains(filter, ignoreCase = true) }
)
private val _filterFlow = MutableStateFlow(value = "")
val filterFlow: Flow<String> get() = _filterFlow
fun filter(criteria: String) {
viewModelScope.launch { _filterFlow.emit(criteria) }
}
fun clear() {
viewModelScope.launch { _filterFlow.emit("") }
}
val search = Pager(
config = PagingConfig(pageSize = SEARCH_PAGE_SIZE),
pagingSourceFactory = bookRepository.getBooksSource()
.asPagingSourceFactory(Dispatchers.IO)
).flow.cachedIn(viewModelScope)
.combine(filterFlow) { paging, filter ->
paging.filter { it.title.contains(filter, ignoreCase = true) }
}
.combine(authors.confirmFlow) { paging, filter ->
paging.filter { filter == null || it.author.any { author -> author.id == filter.id } }
}
.combine(series.confirmFlow) { paging, filter ->
paging.filter { filter == null || it.series?.id == filter.id }
}
.combine(genre.confirmFlow) { paging, filter ->
paging.filter { filter == null || it.genre?.any { author -> author.id == filter.id } ?: false}
}
.combine(language.confirmFlow) { paging, filter ->
paging.filter { filter == null || it.language?.id == filter.id }
}
.map { paging -> paging.map { it.toMicroThumbnailUio() } }
data class FilterUio(
val id: Int,
val label: String,
)
class CategoryFilterManager<T : Any>(
private val scope: CoroutineScope,
source: () -> PagingSource<Int, T>,
sourceFilter: (T, String) -> Boolean,
) {
private val _filterFlow = MutableStateFlow(value = "")
val filterFlow: Flow<String> get() = _filterFlow
private val _confirmFlow = MutableStateFlow<T?>(value = null)
val confirmFlow: Flow<T?> get() = _confirmFlow
val dataFlow: Flow<PagingData<T>> = Pager(
config = PagingConfig(pageSize = 30),
pagingSourceFactory = source,
).flow.cachedIn(scope).combine(this._filterFlow) { paging, filter ->
paging.filter { sourceFilter(it, filter) }
}
var search: T? by mutableStateOf(null)
private set
fun filter(criteria: String) {
scope.launch { _filterFlow.emit(criteria) }
}
fun clear() {
search = null
scope.launch { _confirmFlow.emit(null) }
}
fun confirm(criteria: T?) {
search = criteria
scope.launch { _confirmFlow.emit(criteria) }
}
}
companion object {
private const val SEARCH_PAGE_SIZE = 10
}
}

View file

@ -2,51 +2,49 @@ package com.pixelized.biblib.ui.screen.home.page.search.item
import android.content.res.Configuration
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import com.pixelized.biblib.ui.scaffold.LocalSearchViewModel
import com.pixelized.biblib.R
import com.pixelized.biblib.ui.scaffold.SearchFilter
import com.pixelized.biblib.ui.screen.home.page.search.SearchViewModel
import com.pixelized.biblib.ui.theme.BibLibTheme
import com.pixelized.biblib.utils.extention.bibLib
import com.pixelized.biblib.utils.extention.default
@Stable
@Immutable
data class SearchFilterUio(
val id: SearchFilter,
val label: String,
val value: String? = null,
)
@Composable
fun SearchFilter(
modifier: Modifier = Modifier,
filters: List<SearchFilter> = SearchFilter.all,
onFilter: (filter: SearchFilter) -> Unit = default<SearchFilter>(),
fun SearchFilterList(
filters: List<SearchFilterUio>,
onFilter: (filter: SearchFilterUio) -> Unit = default<SearchFilterUio>(),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState())
.then(modifier),
.padding(horizontal = MaterialTheme.bibLib.dimen.dp16),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(MaterialTheme.bibLib.dimen.dp8),
) {
filters.forEachIndexed { index, filter ->
val chipModifier = if (index != filters.lastIndex) {
Modifier.padding(end = MaterialTheme.bibLib.dimen.dp8)
} else {
Modifier
}
SearchChipFilter(
modifier = chipModifier,
selected = filter.isSelected,
label = stringResource(id = filter.label),
value = filter.value,
filters.forEach { filter ->
SearchFilter(
uio = filter,
onClick = { onFilter(filter) }
)
}
@ -55,25 +53,23 @@ fun SearchFilter(
@OptIn(ExperimentalMaterialApi::class)
@Composable
private fun SearchChipFilter(
private fun SearchFilter(
modifier: Modifier = Modifier,
label: String,
selected: Boolean = false,
value: String? = null,
uio: SearchFilterUio,
onClick: () -> Unit = default(),
) {
FilterChip(
modifier = modifier,
selected = selected,
selected = uio.value != null,
onClick = onClick,
) {
Text(
color = MaterialTheme.bibLib.colors.typography.medium,
style = MaterialTheme.typography.caption,
text = label
text = uio.label,
)
value?.let {
uio.value?.let {
Text(
color = MaterialTheme.bibLib.colors.typography.medium,
style = MaterialTheme.typography.caption,
@ -82,7 +78,7 @@ private fun SearchChipFilter(
Text(
color = MaterialTheme.bibLib.colors.typography.medium,
style = MaterialTheme.typography.caption,
text = value
text = it
)
}
@ -94,28 +90,34 @@ private fun SearchChipFilter(
}
}
@Composable
fun rememberSearchFilter(
searchViewModel: SearchViewModel = LocalSearchViewModel.current
): List<SearchFilter> {
val filters by remember {
derivedStateOf {
listOf(
SearchFilter.Author(value = searchViewModel.authors.search?.label),
SearchFilter.Series(value = searchViewModel.series.search?.label),
SearchFilter.Genre(value = searchViewModel.genre.search?.label),
SearchFilter.Language(value = searchViewModel.language.search?.label)
)
}
}
return filters
}
@Composable
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_NO)
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
private fun SearchFilterPreview() {
BibLibTheme {
SearchFilter()
SearchFilterList(
filters = searchFilterPreviewItems()
)
}
}
}
@Composable
fun searchFilterPreviewItems() = listOf(
SearchFilterUio(
id = SearchFilter.Author,
label = stringResource(id = R.string.search_filter_author),
value = "Asimov",
),
SearchFilterUio(
id = SearchFilter.Genre,
label = stringResource(id = R.string.search_filter_genre),
),
SearchFilterUio(
id = SearchFilter.Series,
label = stringResource(id = R.string.search_filter_serie),
),
SearchFilterUio(
id = SearchFilter.Language,
label = stringResource(id = R.string.search_filter_language),
),
)

View file

@ -2,6 +2,7 @@ package com.pixelized.biblib.ui.screen.home.page.search.item
import android.content.res.Configuration
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
@ -9,28 +10,40 @@ import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.History
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import com.pixelized.biblib.ui.theme.BibLibTheme
import com.pixelized.biblib.utils.extention.bibLib
@Stable
@Immutable
data class SearchHistoryUio(
val label: String,
)
@Composable
fun SearchHistory() {
fun SearchHistory(
uio: SearchHistoryUio,
) {
SearchHistoryContent(
label = ""
modifier = Modifier.fillMaxWidth(),
label = uio.label
)
}
@Composable
private fun SearchHistoryContent(
modifier: Modifier = Modifier,
label : String,
label: String,
) {
Row(
modifier = modifier.padding(all = MaterialTheme.bibLib.dimen.dp16)
) {
Icon(
modifier = Modifier.padding(end = MaterialTheme.bibLib.dimen.dp8),
tint = MaterialTheme.colors.onSurface,
imageVector = Icons.Default.History,
contentDescription = null,
)
@ -47,6 +60,8 @@ private fun SearchHistoryContent(
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
private fun SearchHistoryContentPreview() {
BibLibTheme {
SearchHistoryContentPreview()
SearchHistory(
uio = SearchHistoryUio("Asimov")
)
}
}

View file

@ -0,0 +1,38 @@
package com.pixelized.biblib.ui.screen.home.page.search.source
import android.util.Log
import androidx.paging.PagingSource
import androidx.paging.PagingState
import com.pixelized.biblib.model.book.Author
import com.pixelized.biblib.repository.search.ISearchRepository
import com.pixelized.biblib.utils.extention.page
class AuthorSearchSource(
private val searchRepository: ISearchRepository,
private val search: String?,
private val limit: Int,
) : PagingSource<Int, Author>() {
override fun getRefreshKey(state: PagingState<Int, Author>): Int? {
return null
}
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Author> {
return try {
val index = params.page
val page = searchRepository.searchAuthor(
search = search,
limit = limit,
offset = index * limit
)
LoadResult.Page(
data = page,
prevKey = if (index == 0) null else index - 1,
nextKey = if (page.count() < limit) null else index + 1,
)
} catch (exception: Exception) {
Log.e(this::class.java.simpleName, exception.message, exception)
LoadResult.Error(throwable = exception)
}
}
}

View file

@ -0,0 +1,46 @@
package com.pixelized.biblib.ui.screen.home.page.search.source
import android.util.Log
import androidx.paging.PagingSource
import androidx.paging.PagingState
import com.pixelized.biblib.model.book.Book
import com.pixelized.biblib.repository.search.ISearchRepository
import com.pixelized.biblib.utils.extention.page
class BookSearchSource(
private val searchRepository: ISearchRepository,
private val search: String?,
private val authorId: Int?,
private val seriesId: Int?,
private val genreId: Int?,
private val languageId: Int?,
private val limit: Int,
) : PagingSource<Int, Book>() {
override fun getRefreshKey(state: PagingState<Int, Book>): Int? {
return null
}
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Book> {
return try {
val index = params.page
val page = searchRepository.searchBooks(
search = search,
authorId = authorId,
seriesId = seriesId,
genreId = genreId,
languageId = languageId,
limit = limit,
offset = index * limit
)
LoadResult.Page(
data = page,
prevKey = if (index == 0) null else index - 1,
nextKey = if (page.count() < limit) null else index + 1,
)
} catch (exception: Exception) {
Log.e(this::class.java.simpleName, exception.message, exception)
LoadResult.Error(throwable = exception)
}
}
}

View file

@ -0,0 +1,39 @@
package com.pixelized.biblib.ui.screen.home.page.search.source
import android.util.Log
import androidx.paging.PagingSource
import androidx.paging.PagingState
import com.pixelized.biblib.model.book.Author
import com.pixelized.biblib.model.book.Genre
import com.pixelized.biblib.repository.search.ISearchRepository
import com.pixelized.biblib.utils.extention.page
class GenreSearchSource(
private val searchRepository: ISearchRepository,
private val search: String?,
private val limit: Int,
) : PagingSource<Int, Genre>() {
override fun getRefreshKey(state: PagingState<Int, Genre>): Int? {
return null
}
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Genre> {
return try {
val index = params.page
val page = searchRepository.searchGenre(
search = search,
limit = limit,
offset = index * limit
)
LoadResult.Page(
data = page,
prevKey = if (index == 0) null else index - 1,
nextKey = if (page.count() < limit) null else index + 1,
)
} catch (exception: Exception) {
Log.e(this::class.java.simpleName, exception.message, exception)
LoadResult.Error(throwable = exception)
}
}
}

View file

@ -0,0 +1,39 @@
package com.pixelized.biblib.ui.screen.home.page.search.source
import android.util.Log
import androidx.paging.PagingSource
import androidx.paging.PagingState
import com.pixelized.biblib.model.book.Language
import com.pixelized.biblib.model.book.Series
import com.pixelized.biblib.repository.search.ISearchRepository
import com.pixelized.biblib.utils.extention.page
class LanguageSearchSource(
private val searchRepository: ISearchRepository,
private val search: String?,
private val limit: Int,
) : PagingSource<Int, Language>() {
override fun getRefreshKey(state: PagingState<Int, Language>): Int? {
return null
}
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Language> {
return try {
val index = params.page
val page = searchRepository.searchLanguage(
search = search,
limit = limit,
offset = index * limit
)
LoadResult.Page(
data = page,
prevKey = if (index == 0) null else index - 1,
nextKey = if (page.count() < limit) null else index + 1,
)
} catch (exception: Exception) {
Log.e(this::class.java.simpleName, exception.message, exception)
LoadResult.Error(throwable = exception)
}
}
}

View file

@ -0,0 +1,40 @@
package com.pixelized.biblib.ui.screen.home.page.search.source
import android.util.Log
import androidx.paging.PagingSource
import androidx.paging.PagingState
import com.pixelized.biblib.model.book.Author
import com.pixelized.biblib.model.book.Genre
import com.pixelized.biblib.model.book.Series
import com.pixelized.biblib.repository.search.ISearchRepository
import com.pixelized.biblib.utils.extention.page
class SeriesSearchSource(
private val searchRepository: ISearchRepository,
private val search: String?,
private val limit: Int,
) : PagingSource<Int, Series>() {
override fun getRefreshKey(state: PagingState<Int, Series>): Int? {
return null
}
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Series> {
return try {
val index = params.page
val page = searchRepository.searchSeries(
search = search,
limit = limit,
offset = index * limit
)
LoadResult.Page(
data = page,
prevKey = if (index == 0) null else index - 1,
nextKey = if (page.count() < limit) null else index + 1,
)
} catch (exception: Exception) {
Log.e(this::class.java.simpleName, exception.message, exception)
LoadResult.Error(throwable = exception)
}
}
}

View file

@ -0,0 +1,61 @@
package com.pixelized.biblib.ui.screen.home.page.search.viewModel
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.paging.*
import com.pixelized.biblib.model.book.Author
import com.pixelized.biblib.repository.search.ISearchRepository
import com.pixelized.biblib.ui.screen.home.page.search.FilterUio
import com.pixelized.biblib.ui.screen.home.page.search.source.AuthorSearchSource
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.plus
import javax.inject.Inject
@HiltViewModel
class AuthorFilterViewModel @Inject constructor(
private val searchRepository: ISearchRepository,
) : ViewModel(), IFilterViewModel {
private var source: AuthorSearchSource? = null
override val paging: Flow<PagingData<FilterUio>>
override var search: String? by mutableStateOf(null)
private set
init {
val authorFlow = Pager(
config = PagingConfig(
pageSize = AUTHOR_FILTER_PAGE_SIZE,
enablePlaceholders = true,
),
pagingSourceFactory = ::buildSource,
).flow
paging = authorFlow
.map { it.map { data -> FilterUio(id = data.id, filter = data.name) } }
.cachedIn(viewModelScope + Dispatchers.IO)
}
override fun updateSearch(criteria: String?) {
this.search = criteria
source?.invalidate()
}
private fun buildSource(): PagingSource<Int, Author> {
return AuthorSearchSource(
searchRepository = searchRepository,
search = search,
limit = AUTHOR_FILTER_PAGE_SIZE
).also {
source = it
}
}
companion object {
private const val AUTHOR_FILTER_PAGE_SIZE = 20
}
}

View file

@ -0,0 +1,94 @@
package com.pixelized.biblib.ui.screen.home.page.search.viewModel
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.paging.*
import com.pixelized.biblib.model.book.Book
import com.pixelized.biblib.repository.search.ISearchRepository
import com.pixelized.biblib.ui.screen.home.common.item.MicroBookThumbnailUio
import com.pixelized.biblib.ui.screen.home.page.search.FilterUio
import com.pixelized.biblib.ui.screen.home.page.search.source.BookSearchSource
import com.pixelized.biblib.utils.extention.toMicroThumbnailUio
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.plus
import javax.inject.Inject
@HiltViewModel
class BookSearchViewModel @Inject constructor(
private val searchRepository: ISearchRepository,
) : ViewModel() {
private var searchSource: BookSearchSource? = null
val paging: Flow<PagingData<MicroBookThumbnailUio>>
var search: String? by mutableStateOf(null)
private set
var author: FilterUio? by mutableStateOf(null)
private set
var series: FilterUio? by mutableStateOf(null)
private set
var genre: FilterUio? by mutableStateOf(null)
private set
var language: FilterUio? by mutableStateOf(null)
private set
init {
val searchFlow = Pager(
config = PagingConfig(
pageSize = SEARCH_PAGE_SIZE,
enablePlaceholders = true,
),
pagingSourceFactory = ::buildBookSource,
).flow
// keep transaction updated with the pager.
paging = searchFlow
.map { pagingData -> pagingData.map { it.toMicroThumbnailUio() } }
.cachedIn(viewModelScope + Dispatchers.IO)
}
fun filterSearch(criteria: String) {
this.search = criteria
searchSource?.invalidate()
}
fun filterAuthor(criteria: FilterUio?) {
author = criteria
searchSource?.invalidate()
}
fun filterSeries(criteria: FilterUio?) {
series = criteria
searchSource?.invalidate()
}
fun filterGenre(criteria: FilterUio?) {
genre = criteria
searchSource?.invalidate()
}
fun filterLanguage(criteria: FilterUio?) {
language = criteria
searchSource?.invalidate()
}
private fun buildBookSource(): PagingSource<Int, Book> {
return BookSearchSource(
searchRepository = searchRepository,
search = search,
authorId = author?.id,
seriesId = series?.id,
genreId = genre?.id,
languageId = language?.id,
limit = SEARCH_PAGE_SIZE,
).also {
searchSource = it
}
}
companion object {
private const val SEARCH_PAGE_SIZE = 20
}
}

View file

@ -0,0 +1,61 @@
package com.pixelized.biblib.ui.screen.home.page.search.viewModel
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.paging.*
import com.pixelized.biblib.model.book.Genre
import com.pixelized.biblib.repository.search.ISearchRepository
import com.pixelized.biblib.ui.screen.home.page.search.FilterUio
import com.pixelized.biblib.ui.screen.home.page.search.source.GenreSearchSource
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.plus
import javax.inject.Inject
@HiltViewModel
class GenreFilterViewModel @Inject constructor(
private val searchRepository: ISearchRepository,
) : ViewModel(), IFilterViewModel {
private var source: GenreSearchSource? = null
override val paging: Flow<PagingData<FilterUio>>
override var search: String? by mutableStateOf(null)
private set
init {
val authorFlow = Pager(
config = PagingConfig(
pageSize = AUTHOR_FILTER_PAGE_SIZE,
enablePlaceholders = true,
),
pagingSourceFactory = ::buildSource,
).flow
paging = authorFlow
.map { it.map { data -> FilterUio(id = data.id, filter = data.name) } }
.cachedIn(viewModelScope + Dispatchers.IO)
}
override fun updateSearch(criteria: String?) {
this.search = criteria
source?.invalidate()
}
private fun buildSource(): PagingSource<Int, Genre> {
return GenreSearchSource(
searchRepository = searchRepository,
search = search,
limit = AUTHOR_FILTER_PAGE_SIZE
).also {
source = it
}
}
companion object {
private const val AUTHOR_FILTER_PAGE_SIZE = 20
}
}

View file

@ -0,0 +1,12 @@
package com.pixelized.biblib.ui.screen.home.page.search.viewModel
import androidx.paging.PagingData
import com.pixelized.biblib.ui.screen.home.page.search.FilterUio
import kotlinx.coroutines.flow.Flow
interface IFilterViewModel {
val paging: Flow<PagingData<FilterUio>>
val search: String?
fun updateSearch(criteria: String?)
}

View file

@ -0,0 +1,61 @@
package com.pixelized.biblib.ui.screen.home.page.search.viewModel
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.paging.*
import com.pixelized.biblib.model.book.Language
import com.pixelized.biblib.repository.search.ISearchRepository
import com.pixelized.biblib.ui.screen.home.page.search.FilterUio
import com.pixelized.biblib.ui.screen.home.page.search.source.LanguageSearchSource
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.plus
import javax.inject.Inject
@HiltViewModel
class LanguageFilterViewModel @Inject constructor(
private val searchRepository: ISearchRepository,
) : ViewModel(), IFilterViewModel {
private var source: LanguageSearchSource? = null
override val paging: Flow<PagingData<FilterUio>>
override var search: String? by mutableStateOf(null)
private set
init {
val authorFlow = Pager(
config = PagingConfig(
pageSize = AUTHOR_FILTER_PAGE_SIZE,
enablePlaceholders = true,
),
pagingSourceFactory = ::buildSource,
).flow
paging = authorFlow
.map { it.map { data -> FilterUio(id = data.id, filter = data.displayLanguage) } }
.cachedIn(viewModelScope + Dispatchers.IO)
}
override fun updateSearch(criteria: String?) {
this.search = criteria
source?.invalidate()
}
private fun buildSource(): PagingSource<Int, Language> {
return LanguageSearchSource(
searchRepository = searchRepository,
search = search,
limit = AUTHOR_FILTER_PAGE_SIZE
).also {
source = it
}
}
companion object {
private const val AUTHOR_FILTER_PAGE_SIZE = 20
}
}

View file

@ -0,0 +1,61 @@
package com.pixelized.biblib.ui.screen.home.page.search.viewModel
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.paging.*
import com.pixelized.biblib.model.book.Series
import com.pixelized.biblib.repository.search.ISearchRepository
import com.pixelized.biblib.ui.screen.home.page.search.FilterUio
import com.pixelized.biblib.ui.screen.home.page.search.source.SeriesSearchSource
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.plus
import javax.inject.Inject
@HiltViewModel
class SeriesFilterViewModel @Inject constructor(
private val searchRepository: ISearchRepository,
) : ViewModel(), IFilterViewModel {
private var source: SeriesSearchSource? = null
override val paging: Flow<PagingData<FilterUio>>
override var search: String? by mutableStateOf(null)
private set
init {
val authorFlow = Pager(
config = PagingConfig(
pageSize = AUTHOR_FILTER_PAGE_SIZE,
enablePlaceholders = true,
),
pagingSourceFactory = ::buildSource,
).flow
paging = authorFlow
.map { it.map { data -> FilterUio(id = data.id, filter = data.name) } }
.cachedIn(viewModelScope + Dispatchers.IO)
}
override fun updateSearch(criteria: String?) {
this.search = criteria
source?.invalidate()
}
private fun buildSource(): PagingSource<Int, Series> {
return SeriesSearchSource(
searchRepository = searchRepository,
search = search,
limit = AUTHOR_FILTER_PAGE_SIZE
).also {
source = it
}
}
companion object {
private const val AUTHOR_FILTER_PAGE_SIZE = 20
}
}

View file

@ -0,0 +1,6 @@
package com.pixelized.biblib.utils.extention
import androidx.paging.PagingSource
val PagingSource.LoadParams<Int>.page: Int
get() = key ?: 0