First implementation of a complete Search

This commit is contained in:
Thomas Andres Gomez 2022-07-06 19:08:47 +02:00
parent 5cc5890926
commit 182c2bda48
25 changed files with 313 additions and 119 deletions

View file

@ -2,16 +2,16 @@
"formatVersion": 1,
"database": {
"version": 1,
"identityHash": "eb9a9ac9ba58cccd34d5fa89bcd4151c",
"identityHash": "3f612998c4903a247d1c955da20b272d",
"entities": [
{
"tableName": "AUTHOR",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`AUTHOR_ID` TEXT NOT NULL, `AUTHOR_NAME` TEXT NOT NULL, `AUTHOR_SORT` TEXT NOT NULL, PRIMARY KEY(`AUTHOR_ID`))",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`AUTHOR_ID` INTEGER NOT NULL, `AUTHOR_NAME` TEXT NOT NULL, `AUTHOR_SORT` TEXT NOT NULL, PRIMARY KEY(`AUTHOR_ID`))",
"fields": [
{
"fieldPath": "id",
"columnName": "AUTHOR_ID",
"affinity": "TEXT",
"affinity": "INTEGER",
"notNull": true
},
{
@ -38,7 +38,7 @@
},
{
"tableName": "BOOK",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`BOOK_ID` INTEGER NOT NULL, `BOOK_TITLE` TEXT NOT NULL, `BOOK_SORT` TEXT NOT NULL, `BOOK_HAVE_COVER` INTEGER NOT NULL, `BOOK_RELEASE_DATE` INTEGER NOT NULL, `BOOK_LANGUAGE_ID` TEXT, `BOOK_RATING` INTEGER, `BOOK_SERIES_ID` INTEGER, `BOOK_SYNOPSIS` TEXT, `BOOK_ISNEW` INTEGER NOT NULL, `BOOK_NEW_ORDER` INTEGER, PRIMARY KEY(`BOOK_ID`))",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`BOOK_ID` INTEGER NOT NULL, `BOOK_TITLE` TEXT NOT NULL, `BOOK_SORT` TEXT NOT NULL, `BOOK_HAVE_COVER` INTEGER NOT NULL, `BOOK_RELEASE_DATE` INTEGER NOT NULL, `BOOK_LANGUAGE_ID` INTEGER, `BOOK_RATING` INTEGER, `BOOK_SERIES_ID` INTEGER, `BOOK_SYNOPSIS` TEXT, `BOOK_ISNEW` INTEGER NOT NULL, `BOOK_NEW_ORDER` INTEGER, PRIMARY KEY(`BOOK_ID`))",
"fields": [
{
"fieldPath": "id",
@ -73,7 +73,7 @@
{
"fieldPath": "language",
"columnName": "BOOK_LANGUAGE_ID",
"affinity": "TEXT",
"affinity": "INTEGER",
"notNull": false
},
{
@ -144,12 +144,12 @@
},
{
"tableName": "LANGUAGE",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`LANGUAGE_ID` TEXT NOT NULL, `LANGUAGE_NAME` TEXT NOT NULL, PRIMARY KEY(`LANGUAGE_ID`))",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`LANGUAGE_ID` INTEGER NOT NULL, `LANGUAGE_NAME` TEXT NOT NULL, PRIMARY KEY(`LANGUAGE_ID`))",
"fields": [
{
"fieldPath": "id",
"columnName": "LANGUAGE_ID",
"affinity": "TEXT",
"affinity": "INTEGER",
"notNull": true
},
{
@ -208,7 +208,7 @@
},
{
"tableName": "BookAuthorCrossRef",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`BOOK_ID` INTEGER NOT NULL, `AUTHOR_ID` TEXT NOT NULL, PRIMARY KEY(`BOOK_ID`, `AUTHOR_ID`))",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`BOOK_ID` INTEGER NOT NULL, `AUTHOR_ID` INTEGER NOT NULL, PRIMARY KEY(`BOOK_ID`, `AUTHOR_ID`))",
"fields": [
{
"fieldPath": "bookId",
@ -219,7 +219,7 @@
{
"fieldPath": "authorId",
"columnName": "AUTHOR_ID",
"affinity": "TEXT",
"affinity": "INTEGER",
"notNull": true
}
],
@ -284,7 +284,7 @@
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'eb9a9ac9ba58cccd34d5fa89bcd4151c')"
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '3f612998c4903a247d1c955da20b272d')"
]
}
}

View file

@ -10,5 +10,5 @@ data class BookAuthorCrossRef(
@ColumnInfo(name = BookDbo.ID)
val bookId: Int,
@ColumnInfo(name = AuthorDbo.ID, index = true)
val authorId: String
val authorId: Int,
)

View file

@ -8,7 +8,7 @@ import androidx.room.PrimaryKey
data class AuthorDbo(
@PrimaryKey
@ColumnInfo(name = ID)
val id: String,
val id: Int,
@ColumnInfo(name = NAME)
val name: String,
@ColumnInfo(name = SORT)

View file

@ -19,7 +19,7 @@ data class BookDbo(
@ColumnInfo(name = RELEASE_DATE)
val releaseDate: Date,
@ColumnInfo(name = LANGUAGE_ID)
val language: String? = null, // one-to-many
val language: Int? = null, // one-to-many
@ColumnInfo(name = RATING)
val rating: Int? = null,
// details

View file

@ -8,7 +8,7 @@ import androidx.room.PrimaryKey
data class LanguageDbo(
@PrimaryKey
@ColumnInfo(name = ID)
val id: String,
val id: Int,
@ColumnInfo(name = NAME)
val code: String,
) {

View file

@ -1,7 +1,7 @@
package com.pixelized.biblib.model.book
data class Author(
val id: String,
val id: Int,
val name: String,
val sort: String,
)

View file

@ -2,8 +2,8 @@ package com.pixelized.biblib.model.book
import java.util.*
data class Language (
val id : String,
data class Language(
val id: Int,
val code: String,
) {
val displayLanguage: String by lazy { Locale(code).displayLanguage }

View file

@ -1,7 +1,7 @@
package com.pixelized.biblib.model.book
data class Series(
val id: Int?,
val id: Int,
val name: String,
val sort: String,
val index: Int?,

View file

@ -23,7 +23,7 @@ class BookFactory {
val id: Int? = response.book_id
val title: String? = response.book_title
val sort: String? = response.book_sort
val authorId: List<String>? = response.author_id
val authorId: List<Int>? = response.author_id?.mapNotNull { it.toIntOrNull() }
val authorName: List<String>? = response.author_name
val authorSort: List<String>? = response.author_sort
val authorIdSize = authorId?.size ?: 0
@ -54,7 +54,7 @@ class BookFactory {
}
val cover: Boolean? = response.book_has_cover?.toBoolean()
val releaseDate = response.book_date?.let { parser.parse(it) }
val languageId = response.lang_id
val languageId = response.lang_id?.toIntOrNull()
val languageCode = response.lang_code
val language = if (languageId != null && languageCode != null) {
Language(languageId, languageCode)
@ -91,7 +91,7 @@ class BookFactory {
val id: Int? = response.book_id
val title: String? = response.book_title
val sort: String? = response.book_sort
val authorId: List<String>? = response.author_id
val authorId: List<Int>? = response.author_id?.mapNotNull { it.toIntOrNull() }
val authorName: List<String>? = response.author_name
val authorSort: List<String>? = response.author_sort
val authorIdSize = authorId?.size
@ -111,7 +111,7 @@ class BookFactory {
}
val cover: Boolean? = response.book_has_cover?.toBoolean()
val releaseDate = response.book_date?.let { parser.parse(it) }
val languageId = response.lang_id
val languageId = response.lang_id?.toIntOrNull()
val languageCode = response.lang_code
val language = if (languageId != null && languageCode != null) {
Language(languageId, languageCode)

View file

@ -16,7 +16,7 @@ class SeriesFactory {
response.series?.forEachIndexed { index, data ->
// build the Series items
val series = Series(
id = data.series_id,
id = data.series_id ?: throw error("id"),
name = data.series_name ?: throw error("name"),
sort = data.series_sort ?: throw error("sort"),
index = index,

View file

@ -15,11 +15,11 @@ interface IBookRepository {
fun getAuthorsSource() : DataSource.Factory<Int, Author>
fun getSeriesSource() : DataSource.Factory<Int, Series>
fun getGenresSource() : DataSource.Factory<Int, Genre>
fun getSeriesSource(): DataSource.Factory<Int, Series>
fun getLanguagesSource() : DataSource.Factory<Int, Language>
fun getGenresSource(): DataSource.Factory<Int, Genre>
fun getLanguagesSource(): DataSource.Factory<Int, Language>
suspend fun update(data: List<Book>)
}

View file

@ -22,7 +22,6 @@ import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.pixelized.biblib.R
import com.pixelized.biblib.ui.scaffold.SearchScaffoldState
import com.pixelized.biblib.ui.scaffold.SearchScaffoldState.ContentState
@ -38,10 +37,12 @@ fun Search(
modifier: Modifier = Modifier,
state: SearchScaffoldState = rememberSearchScaffoldState(),
focusRequester: FocusRequester = remember { FocusRequester() },
avatar: String?,
onClose: () -> Unit = default(),
onAvatar: () -> Unit = default(),
onSearch: () -> Unit = default(),
avatar: String? = null,
searchValue: String = "",
onSearchValueChange: (String) -> Unit = default<String>(),
onCloseTap: () -> Unit = default(),
onAvatarTap: () -> Unit = default(),
onSearchTap: () -> Unit = default(),
) {
val horizontalPadding by animateDpAsState(
targetValue = when (state.isCollapsed()) {
@ -49,17 +50,16 @@ fun Search(
else -> MaterialTheme.bibLib.dimen.dp4
}
)
var search by remember { mutableStateOf("") }
Row(
modifier = Modifier
.clickable(onClick = onSearch)
.clickable(onClick = onSearchTap)
.then(modifier),
verticalAlignment = Alignment.CenterVertically
) {
IconButton(
modifier = Modifier.padding(start = horizontalPadding),
onClick = if (state.content != ContentState.INITIAL) onClose else onSearch,
onClick = if (state.content != ContentState.INITIAL) onCloseTap else onSearchTap,
) {
Icon(
imageVector = when (state.content) {
@ -84,7 +84,7 @@ fun Search(
}
)
},
value = if (state.content == ContentState.SEARCH) search else "",
value = if (state.content == ContentState.SEARCH) searchValue else "",
singleLine = true,
enabled = state.content == ContentState.SEARCH,
readOnly = state.content != ContentState.SEARCH,
@ -93,12 +93,12 @@ fun Search(
unfocusedBorderColor = Color.Transparent,
disabledBorderColor = Color.Transparent,
),
onValueChange = { search = it }
onValueChange = onSearchValueChange
)
IconButton(
modifier = Modifier.padding(end = horizontalPadding),
onClick = onAvatar,
onClick = onAvatarTap,
) {
val imageModifier = Modifier
.clip(CircleShape)

View file

@ -14,9 +14,9 @@ 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.detail.BookDetailViewModel
import com.pixelized.biblib.ui.screen.detail.DetailScreen
import com.pixelized.biblib.ui.screen.home.common.uio.BookUio
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.BookUio
import com.pixelized.biblib.utils.extention.showToast
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch

View file

@ -50,10 +50,10 @@ fun SearchBottomSheet(
filter = state.filter,
onClose = {
when(state.filter) {
is SearchFilter.Author -> searchViewModel.authors.confirm("")
is SearchFilter.Series -> searchViewModel.series.confirm("")
is SearchFilter.Genre -> searchViewModel.genre.confirm("")
is SearchFilter.Language -> searchViewModel.language.confirm("")
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()

View file

@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusManager
@ -22,6 +23,7 @@ import com.google.accompanist.pager.ExperimentalPagerApi
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.composable.Search
import com.pixelized.biblib.ui.scaffold.*
import com.pixelized.biblib.ui.scaffold.SearchScaffoldState.ContentState
@ -38,7 +40,9 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@OptIn(ExperimentalComposeUiApi::class, ExperimentalAnimationApi::class,
@OptIn(
ExperimentalComposeUiApi::class,
ExperimentalAnimationApi::class,
ExperimentalMaterialApi::class
)
@Composable
@ -58,16 +62,22 @@ fun HomeScreen(
modifier = Modifier.statusBarsPadding(),
state = state,
topBar = {
val viewModel = LocalSearchViewModel.current
val search by viewModel.filterFlow.collectAsState(initial = "")
Search(
state = state,
avatar = accountViewModel.avatar,
focusRequester = focusRequester,
onClose = {
onCloseTap = {
focusManager.clearFocus(force = true)
keyboard?.hide()
state.collapse()
},
onSearch = {
searchValue = search,
onSearchValueChange = {
viewModel.filter(criteria = it)
},
onSearchTap = {
if (state.content != ContentState.SEARCH || state.isCollapsed()) {
state.expand(ContentState.SEARCH)
scope.launch {
@ -80,7 +90,7 @@ fun HomeScreen(
state.collapse()
}
},
onAvatar = {
onAvatarTap = {
if (state.content != ContentState.PROFILE || state.isCollapsed()) {
focusManager.clearFocus(force = true)
keyboard?.hide()
@ -131,7 +141,9 @@ fun HomeScreen(
val bottomSearchState = LocalSearchBottomSheetState.current
BackHandler(state.isCollapsed().not() && bottomSearchState.bottomSheetState.isVisible.not()) {
BackHandler(
state.isCollapsed().not() && bottomSearchState.bottomSheetState.isVisible.not()
) {
state.collapse()
}
}
@ -173,14 +185,28 @@ fun HomeScreenContent(
when (pages[it]) {
is Page.News -> NewsPage()
is Page.Books -> BooksPage()
is Page.Author -> Box(modifier = Modifier)
is Page.Series -> Box(modifier = Modifier)
is Page.Tag -> Box(modifier = Modifier)
is Page.Author -> NotYetImplemented()
is Page.Series -> NotYetImplemented()
is Page.Tag -> NotYetImplemented()
}
}
}
}
@Composable
private fun NotYetImplemented() {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
Text(
style = MaterialTheme.typography.body1,
color = MaterialTheme.colors.onSurface,
text = stringResource(id = R.string.not_implemented_yet)
)
}
}
@Composable
@Preview(showBackground = true)
private fun HomeScreenContentPreview() {

View file

@ -1,4 +1,4 @@
package com.pixelized.biblib.ui.screen.detail
package com.pixelized.biblib.ui.screen.home.detail
import android.util.Log
import androidx.lifecycle.ViewModel
@ -6,7 +6,6 @@ import com.pixelized.biblib.model.book.Book
import com.pixelized.biblib.network.client.IBibLibClient
import com.pixelized.biblib.network.factory.BookFactory
import com.pixelized.biblib.ui.composable.StateUio
import com.pixelized.biblib.ui.screen.home.common.uio.BookUio
import com.pixelized.biblib.utils.extention.capitalize
import com.pixelized.biblib.utils.extention.shortDate
import dagger.hilt.android.lifecycle.HiltViewModel

View file

@ -1,4 +1,4 @@
package com.pixelized.biblib.ui.screen.home.common.uio
package com.pixelized.biblib.ui.screen.home.detail
import java.io.Serializable

View file

@ -1,4 +1,4 @@
package com.pixelized.biblib.ui.screen.detail
package com.pixelized.biblib.ui.screen.home.detail
import android.content.res.Configuration.UI_MODE_NIGHT_NO
import android.content.res.Configuration.UI_MODE_NIGHT_YES
@ -37,13 +37,13 @@ import com.pixelized.biblib.ui.composable.animation.AnimatedOffset
import com.pixelized.biblib.ui.composable.isSuccessful
import com.pixelized.biblib.ui.scaffold.DetailBottomSheetState
import com.pixelized.biblib.ui.scaffold.LocalDetailBottomSheetState
import com.pixelized.biblib.ui.screen.home.common.uio.BookUio
import com.pixelized.biblib.ui.screen.home.page.profile.ProfileViewModel
import com.pixelized.biblib.ui.screen.home.page.profile.UserUio
import com.pixelized.biblib.ui.theme.BibLibTheme
import com.pixelized.biblib.utils.extention.bibLib
import com.pixelized.biblib.utils.extention.default
import com.skydoves.landscapist.CircularReveal
import com.skydoves.landscapist.ShimmerParams
import com.skydoves.landscapist.glide.GlideImage
import kotlinx.coroutines.launch
@ -129,6 +129,13 @@ private fun DetailScreenContent(
.fillMaxWidth()
.padding(vertical = MaterialTheme.bibLib.dimen.dp16)
.height(MaterialTheme.bibLib.dimen.detail.cover),
loading = {
Box(modifier = Modifier.matchParentSize()) {
CircularProgressIndicator(
modifier = Modifier.align(Alignment.Center)
)
}
},
previewPlaceholder = R.drawable.ic_launcher_foreground,
circularReveal = CircularReveal(duration = 1000),
contentScale = ContentScale.FillHeight,

View file

@ -1,8 +1,6 @@
package com.pixelized.biblib.ui.screen.home.page.books
import android.content.res.Configuration
import androidx.compose.animation.*
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.material.Card

View file

@ -1,7 +1,6 @@
package com.pixelized.biblib.ui.screen.home.page.news
import android.content.res.Configuration
import androidx.compose.animation.*
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.aspectRatio

View file

@ -0,0 +1,10 @@
package com.pixelized.biblib.ui.screen.home.page.search
data class BookSearchUio(
val id: Int,
val title: String,
val author: List<String>,
val language: String? = null,
val genre: List<String>? = null,
val series: String? = null,
)

View file

@ -88,9 +88,9 @@ fun CategorySearchPageContent(
focusRequester: FocusRequester = FocusRequester(),
filter: SearchFilter?,
searchFlow: () -> Flow<String>,
dataFlow: () -> Flow<PagingData<String>> = { emptyFlow() },
dataFlow: () -> Flow<PagingData<SearchViewModel.FilterUio>> = { emptyFlow() },
onSearchChange: (String) -> Unit = default<String>(),
onData: (String) -> Unit = default<String>(),
onData: (SearchViewModel.FilterUio?) -> Unit = default<SearchViewModel.FilterUio?>(),
onClose: () -> Unit = default(),
) {
val data = dataFlow().collectAsLazyPagingItems()
@ -142,15 +142,15 @@ fun CategorySearchPageContent(
LazyColumn(
modifier = Modifier.fillMaxSize()
) {
items(items = data, key = { it }) {
items(items = data, key = { it.id }) {
Text(
modifier = Modifier
.clickable { onData(it ?: "") }
.clickable { onData(it) }
.fillMaxWidth()
.padding(all = MaterialTheme.bibLib.dimen.dp16),
style = MaterialTheme.typography.body1,
color = MaterialTheme.colors.onSurface,
text = it ?: ""
text = it?.label ?: ""
)
}
}

View file

@ -2,29 +2,40 @@ package com.pixelized.biblib.ui.screen.home.page.search
import android.content.res.Configuration.UI_MODE_NIGHT_NO
import android.content.res.Configuration.UI_MODE_NIGHT_YES
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.foundation.clickable
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.layout.*
import androidx.compose.foundation.lazy.LazyColumn
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.material.icons.filled.NavigateNext
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.paging.LoadState
import androidx.paging.PagingData
import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.collectAsLazyPagingItems
import androidx.paging.compose.items
import com.pixelized.biblib.ui.scaffold.LocalDetailBottomSheetState
import com.pixelized.biblib.ui.scaffold.LocalSearchBottomSheetState
import com.pixelized.biblib.ui.scaffold.LocalSearchViewModel
import com.pixelized.biblib.ui.scaffold.SearchFilter
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
@Composable
fun SearchPage(
@ -35,6 +46,7 @@ fun SearchPage(
SearchPageContent(
modifier = Modifier.fillMaxWidth(),
search = searchViewModel.search,
filters = filters,
onFilter = {
bottomSearchState.expandSearch(it)
@ -45,9 +57,20 @@ fun SearchPage(
@Composable
private fun SearchPageContent(
modifier: Modifier = Modifier,
search: Flow<PagingData<BookSearchUio>> = emptyFlow(),
filters: List<SearchFilter> = SearchFilter.all,
onFilter: (filter: SearchFilter) -> Unit = default<SearchFilter>(),
) {
val items = search.collectAsLazyPagingItems()
val detail = LocalDetailBottomSheetState.current
SearchLoader(
modifier = Modifier
.fillMaxWidth()
.height(MaterialTheme.bibLib.dimen.dp2),
isLoading = { items.isLoading },
)
LazyColumn(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(MaterialTheme.bibLib.dimen.dp8),
@ -59,6 +82,60 @@ private fun SearchPageContent(
onFilter = onFilter,
)
}
items(items = items, key = { it.id }) {
if (it != null) {
SearchItem(
item = it,
onClick = {
detail.expandBookDetail(it.id)
}
)
}
}
}
}
@Composable
private fun SearchLoader(
modifier: Modifier = Modifier,
isLoading: () -> Boolean,
) {
val alpha by animateFloatAsState(
animationSpec = spring(stiffness = Spring.StiffnessLow),
targetValue = if (isLoading()) 1f else 0f
)
LinearProgressIndicator(
modifier = modifier.alpha(alpha),
color = MaterialTheme.colors.primary,
backgroundColor = Color.Transparent,
)
}
@Composable
private fun SearchItem(
modifier: Modifier = Modifier,
item: BookSearchUio,
onClick: () -> Unit = default()
) {
Row(
modifier = Modifier
.clickable(onClick = onClick)
.fillMaxWidth()
.padding(all = MaterialTheme.bibLib.dimen.dp16)
.then(modifier)
) {
Text(
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.body1,
color = MaterialTheme.colors.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
text = item.title
)
Icon(
imageVector = Icons.Default.NavigateNext,
contentDescription = null
)
}
}
@ -74,13 +151,13 @@ private fun SearchFilter(
.then(modifier)
) {
filters.forEachIndexed { index, filter ->
val modifier = if (index != filters.lastIndex) {
val chipModifier = if (index != filters.lastIndex) {
Modifier.padding(end = MaterialTheme.bibLib.dimen.dp8)
} else {
Modifier
}
SearchChipFilter(
modifier = modifier,
modifier = chipModifier,
selected = filter.isSelected,
label = stringResource(id = filter.label),
value = filter.value,
@ -124,25 +201,46 @@ private fun SearchChipFilter(
private fun rememberSearchFilter(
searchViewModel: SearchViewModel = LocalSearchViewModel.current
): List<SearchFilter> {
val authors by searchViewModel.authors.search
val genre by searchViewModel.genre.search
val language by searchViewModel.language.search
val series by searchViewModel.series.search
val filters by remember {
derivedStateOf {
listOf(
SearchFilter.Author(value = authors),
SearchFilter.Series(value = series),
SearchFilter.Genre(value = genre),
SearchFilter.Language(value = language)
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
}
private val LazyPagingItems<*>.isLoading: Boolean
get() {
val isLoading by derivedStateOf {
loadState.refresh is LoadState.Loading || loadState.append is LoadState.Loading
}
return isLoading
}
@Composable
@Preview(showBackground = true, uiMode = UI_MODE_NIGHT_NO)
@Preview(showBackground = true, uiMode = UI_MODE_NIGHT_YES)
private fun SearchItemPreview() {
BibLibTheme {
SearchItem(
item = BookSearchUio(
id = 0,
title = "Fondation",
author = listOf("Issac Asimov"),
language = "Fr",
genre = listOf("SF, Classic"),
series = "Fondation",
)
)
}
}
@Composable
@Preview(showBackground = true, uiMode = UI_MODE_NIGHT_NO)
@Preview(showBackground = true, uiMode = UI_MODE_NIGHT_YES)

View file

@ -1,9 +1,12 @@
package com.pixelized.biblib.ui.screen.home.page.search
import androidx.compose.runtime.*
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.book.IBookRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CoroutineScope
@ -11,6 +14,7 @@ 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
@ -19,70 +23,122 @@ class SearchViewModel @Inject constructor(
bookRepository: IBookRepository,
) : ViewModel() {
var search: String by mutableStateOf("")
private set
val authors = FilterManager(
val authors = CategoryFilterManager(
scope = viewModelScope,
source = bookRepository.getAuthorsSource()
.map { it.name }
.asPagingSourceFactory(Dispatchers.IO)
.map { FilterUio(it.id, it.name) }
.asPagingSourceFactory(Dispatchers.IO),
sourceFilter = { data, filter -> data.label.contains(filter, ignoreCase = true) }
)
val series = FilterManager(
val series = CategoryFilterManager(
scope = viewModelScope,
source = bookRepository.getSeriesSource()
.map { it.name }
.asPagingSourceFactory(Dispatchers.IO)
.map { FilterUio(it.id, it.name) }
.asPagingSourceFactory(Dispatchers.IO),
sourceFilter = { data, filter -> data.label.contains(filter, ignoreCase = true) }
)
val genre = FilterManager(
val genre = CategoryFilterManager(
scope = viewModelScope,
source = bookRepository.getGenresSource()
.map { it.name }
.asPagingSourceFactory(Dispatchers.IO)
.map { FilterUio(it.id, it.name) }
.asPagingSourceFactory(Dispatchers.IO),
sourceFilter = { data, filter -> data.label.contains(filter, ignoreCase = true) }
)
val language = FilterManager(
val language = CategoryFilterManager(
scope = viewModelScope,
source = bookRepository.getLanguagesSource()
.map { it.displayLanguage }
.asPagingSourceFactory(Dispatchers.IO)
.map { FilterUio(it.id, it.displayLanguage) }
.asPagingSourceFactory(Dispatchers.IO),
sourceFilter = { data, filter -> data.label.contains(filter, ignoreCase = true) }
)
fun search(criteria: String) {
private val _filterFlow = MutableStateFlow(value = "")
val filterFlow: Flow<String> get() = _filterFlow
fun filter(criteria: String) {
viewModelScope.launch { _filterFlow.emit(criteria) }
}
class FilterManager(
private val scope: CoroutineScope,
source: () -> PagingSource<Int, String>,
) {
private val _searchFlow = MutableStateFlow("")
val searchFlow: Flow<String> get() = _searchFlow
fun clear() {
viewModelScope.launch { _filterFlow.emit("") }
}
private val _filterFlow = MutableStateFlow("")
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.isEmpty() || it.author.any { author -> author.id == filter.id } }
}
.combine(series.confirmFlow) { paging, filter ->
paging.filter { filter == null || it.series == null || it.series.id == filter.id }
}
.combine(genre.confirmFlow) { paging, filter ->
paging.filter { filter == null || it.genre.isNullOrEmpty() || it.genre.any { author -> author.id == filter.id } }
}
.combine(language.confirmFlow) { paging, filter ->
paging.filter { filter == null || it.language == null || it.language.id == filter.id }
}
.map { paging -> paging.map { it.toBookSearchUio() } }
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
val dataFlow: Flow<PagingData<String>> = Pager(
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 { it.contains(filter, ignoreCase = true) }
paging.filter { sourceFilter(it, filter) }
}
val filter: State<String>
@Composable get() = _filterFlow.collectAsState(initial = "")
val search: State<String>
@Composable get() = _searchFlow.collectAsState(initial = "")
var search: T? by mutableStateOf(null)
private set
fun filter(criteria: String) {
scope.launch { _filterFlow.emit(criteria) }
}
fun confirm(criteria: String) {
scope.launch { _searchFlow.emit(criteria) }
fun clear() {
search = null
scope.launch { _confirmFlow.emit(null) }
}
fun confirm(criteria: T?) {
search = criteria
scope.launch { _confirmFlow.emit(criteria) }
}
}
private fun Book.toBookSearchUio() = BookSearchUio(
id = id,
title = title,
author = author.map { it.name },
language = language?.displayLanguage,
genre = genre?.map { it.name },
series = series?.name,
)
companion object {
private const val SEARCH_PAGE_SIZE = 15
}
}

View file

@ -10,6 +10,7 @@ import androidx.compose.ui.unit.dp
@Immutable
data class BibLibDimen(
val default: Dp = 0.dp,
val dp2: Dp = 2.dp,
val dp4: Dp = 4.dp,
val dp8: Dp = 8.dp,
val dp16: Dp = 16.dp,