Add SubCategory Search screens.

This commit is contained in:
Thomas Andres Gomez 2022-07-05 16:24:51 +02:00
parent 3b5f1dae97
commit 42b4e414a0
11 changed files with 428 additions and 106 deletions

View file

@ -20,7 +20,6 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.pixelized.biblib.R

View file

@ -2,12 +2,15 @@ package com.pixelized.biblib.ui.scaffold
import android.content.Context
import androidx.activity.compose.BackHandler
import androidx.compose.animation.core.MutableTransitionState
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.ModalBottomSheetLayout
import androidx.compose.material.ModalBottomSheetState
import androidx.compose.material.ModalBottomSheetValue.Hidden
import androidx.compose.material.rememberModalBottomSheetState
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.hilt.navigation.compose.hiltViewModel
@ -37,8 +40,7 @@ fun BottomDetailScaffold(
scrimColor = Color.Black.copy(alpha = 0.37f),
sheetState = bottomDetailState.bottomSheetState,
sheetContent = {
val detail by remember { bottomDetailState.bookDetail }
DetailScreen(detail = detail)
DetailScreen(detail = bottomDetailState.bookDetail)
},
content = content,
)
@ -60,11 +62,15 @@ fun rememberBottomDetailState(
),
): BottomDetailState {
val context: Context = LocalContext.current
val detail = rememberSaveable(scope, viewModel, bottomSheetState) {
mutableStateOf<BookUio?>(null)
}
val controller = BottomDetailState(
context = context,
viewModel = viewModel,
scope = scope,
bottomSheetState = bottomSheetState
bottomSheetState = bottomSheetState,
bookDetail = detail,
)
return remember(scope, viewModel, bottomSheetState) { controller }
}
@ -76,8 +82,9 @@ class BottomDetailState constructor(
private val viewModel: BookDetailViewModel,
private val scope: CoroutineScope,
val bottomSheetState: ModalBottomSheetState,
bookDetail: MutableState<BookUio?>,
) {
var bookDetail = mutableStateOf<BookUio?>(null)
var bookDetail: BookUio? by bookDetail
private set
fun expandBookDetail(id: Int) {
@ -88,7 +95,7 @@ class BottomDetailState constructor(
context.showToast(message = mes)
}
is StateUio.Success -> {
bookDetail.value = book.value
bookDetail = book.value
bottomSheetState.show()
}
else -> Unit

View file

@ -0,0 +1,145 @@
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
import androidx.compose.material.ModalBottomSheetState
import androidx.compose.material.ModalBottomSheetValue.Hidden
import androidx.compose.material.rememberModalBottomSheetState
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.graphics.Color
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 kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import java.io.Serializable
val LocalSearchViewModel = staticCompositionLocalOf<SearchViewModel> {
error("SearchViewModel is not ready yet")
}
val LocalBottomSearchState = staticCompositionLocalOf<BottomSearchState> {
error("BottomSearchState is not ready yet")
}
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun BottomSearchScaffold(
state: BottomSearchState = rememberBottomSearchState(),
searchViewModel: SearchViewModel = hiltViewModel(),
content: @Composable () -> Unit,
) {
CompositionLocalProvider(
LocalSearchViewModel provides searchViewModel,
LocalBottomSearchState provides state,
) {
ModalBottomSheetLayout(
modifier = Modifier.statusBarsPadding(),
scrimColor = Color.Black.copy(alpha = 0.37f),
sheetState = state.bottomSheetState,
sheetContent = {
CategorySearchPage(
searchViewModel = searchViewModel,
focusRequester = state.focusRequester,
filter = state.filter,
onClose = {
state.collapse()
}
)
},
content = content,
)
BackHandler(state.bottomSheetState.isVisible) {
state.collapse()
}
}
}
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun rememberBottomSearchState(
scope: CoroutineScope = rememberCoroutineScope(),
bottomSheetState: ModalBottomSheetState = rememberModalBottomSheetState(
initialValue = Hidden,
skipHalfExpanded = true,
),
): BottomSearchState {
val filter = rememberSaveable(scope, bottomSheetState) {
mutableStateOf<SearchFilter?>(null)
}
val focusRequester = remember {
FocusRequester()
}
val controller = BottomSearchState(
scope = scope,
bottomSheetState = bottomSheetState,
focusRequester = focusRequester,
filter = filter,
)
return remember(scope, bottomSheetState) { controller }
}
@OptIn(ExperimentalMaterialApi::class)
@Stable
class BottomSearchState constructor(
private val scope: CoroutineScope,
val bottomSheetState: ModalBottomSheetState,
val focusRequester: FocusRequester,
filter: MutableState<SearchFilter?>,
) {
var filter: SearchFilter? by filter
private set
fun expandSearch(filter: SearchFilter?) {
this.filter = filter
scope.launch {
bottomSheetState.show()
focusRequester.requestFocus()
}
}
fun collapse() {
scope.launch {
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 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())
}
}

View file

@ -98,26 +98,26 @@ class SearchScaffoldState(
expended: Boolean,
state: ContentState = ContentState.INITIAL,
) {
private var expended: Boolean by mutableStateOf(expended)
private var isExpended: Boolean by mutableStateOf(expended)
var content: ContentState by mutableStateOf(state)
private set
fun isCollapsed(): Boolean = expended.not()
fun isCollapsed(): Boolean = isExpended.not()
fun expand(state: ContentState) {
content = state
expended = true
isExpended = true
}
fun collapse() {
expended = false
isExpended = false
content = ContentState.INITIAL
}
companion object {
val Saver: Saver<SearchScaffoldState, Pair<Boolean, Int>> = Saver(
save = { it.expended to it.content.ordinal },
save = { it.isExpended to it.content.ordinal },
restore = { SearchScaffoldState(it.first, ContentState.values()[it.second]) },
)
}

View file

@ -6,10 +6,7 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.material.MaterialTheme
import androidx.compose.material.ScrollableTabRow
import androidx.compose.material.Tab
import androidx.compose.material.Text
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
@ -26,11 +23,8 @@ import com.google.accompanist.pager.HorizontalPager
import com.google.accompanist.pager.PagerState
import com.google.accompanist.pager.rememberPagerState
import com.pixelized.biblib.ui.composable.Search
import com.pixelized.biblib.ui.scaffold.BottomDetailScaffold
import com.pixelized.biblib.ui.scaffold.SearchScaffold
import com.pixelized.biblib.ui.scaffold.SearchScaffoldState
import com.pixelized.biblib.ui.scaffold.*
import com.pixelized.biblib.ui.scaffold.SearchScaffoldState.ContentState
import com.pixelized.biblib.ui.scaffold.rememberSearchScaffoldState
import com.pixelized.biblib.ui.screen.connectivity.ConnectivityViewModel
import com.pixelized.biblib.ui.screen.home.common.composable.ConnectivityHeader
import com.pixelized.biblib.ui.screen.home.page.Page
@ -44,7 +38,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
fun HomeScreen(
accountViewModel: HomeViewModel = hiltViewModel(),
@ -57,78 +53,82 @@ fun HomeScreen(
val focusRequester: FocusRequester = remember { FocusRequester() }
BottomDetailScaffold {
SearchScaffold(
modifier = Modifier.statusBarsPadding(),
state = state,
topBar = {
Search(
state = state,
avatar = accountViewModel.avatar,
focusRequester = focusRequester,
onSearch = {
if (state.content != ContentState.SEARCH || state.isCollapsed()) {
state.expand(ContentState.SEARCH)
scope.launch {
delay(100)
focusRequester.requestFocus()
BottomSearchScaffold {
SearchScaffold(
modifier = Modifier.statusBarsPadding(),
state = state,
topBar = {
Search(
state = state,
avatar = accountViewModel.avatar,
focusRequester = focusRequester,
onSearch = {
if (state.content != ContentState.SEARCH || state.isCollapsed()) {
state.expand(ContentState.SEARCH)
scope.launch {
delay(100)
focusRequester.requestFocus()
}
} else {
focusManager.clearFocus(force = true)
keyboard?.hide()
state.collapse()
}
} else {
focusManager.clearFocus(force = true)
keyboard?.hide()
state.collapse()
}
},
onAvatar = {
if (state.content != ContentState.PROFILE || state.isCollapsed()) {
focusManager.clearFocus(force = true)
keyboard?.hide()
state.expand(ContentState.PROFILE)
} else {
state.collapse()
}
}
)
},
search = {
Box(modifier = Modifier.fillMaxSize()) {
AnimatedContent(
targetState = state.content,
transitionSpec = {
when {
targetState == ContentState.INITIAL -> {
EnterTransition.None with fadeOut()
}
targetState.ordinal < initialState.ordinal -> {
val enter = slideInHorizontally { -it } + fadeIn()
val exit = slideOutHorizontally { +it } + fadeOut()
enter with exit
}
else -> {
val enter = slideInHorizontally { +it } + fadeIn()
val exit = slideOutHorizontally { -it } + fadeOut()
enter with exit
}
},
onAvatar = {
if (state.content != ContentState.PROFILE || state.isCollapsed()) {
focusManager.clearFocus(force = true)
keyboard?.hide()
state.expand(ContentState.PROFILE)
} else {
state.collapse()
}
}
) {
when (it) {
ContentState.SEARCH -> SearchPage()
ContentState.PROFILE -> ProfilePage()
else -> Unit
)
},
search = {
Box(modifier = Modifier.fillMaxSize()) {
AnimatedContent(
targetState = state.content,
transitionSpec = {
when {
targetState == ContentState.INITIAL -> {
EnterTransition.None with fadeOut()
}
targetState.ordinal < initialState.ordinal -> {
val enter = slideInHorizontally { -it } + fadeIn()
val exit = slideOutHorizontally { +it } + fadeOut()
enter with exit
}
else -> {
val enter = slideInHorizontally { +it } + fadeIn()
val exit = slideOutHorizontally { -it } + fadeOut()
enter with exit
}
}
}
) {
when (it) {
ContentState.SEARCH -> SearchPage()
ContentState.PROFILE -> ProfilePage()
else -> Unit
}
}
}
}
},
content = {
HomeScreenContent(
isNetworkAvailable = { connectivityViewModel.isNetworkAvailable },
pages = Page.all
)
},
)
},
content = {
HomeScreenContent(
isNetworkAvailable = { connectivityViewModel.isNetworkAvailable },
pages = Page.all
)
},
)
BackHandler(state.isCollapsed().not()) {
state.collapse()
val bottomSearchState = LocalBottomSearchState.current
BackHandler(state.isCollapsed().not() && bottomSearchState.bottomSheetState.isVisible.not()) {
state.collapse()
}
}
}
}

View file

@ -1,5 +1,7 @@
package com.pixelized.biblib.ui.screen.home.common.uio
import java.io.Serializable
data class BookUio(
val id: Int,
val title: String,
@ -10,4 +12,4 @@ data class BookUio(
val series: String?,
val description: String,
val cover: String,
)
) : Serializable

View file

@ -0,0 +1,123 @@
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.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
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.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.pixelized.biblib.ui.scaffold.LocalBottomSearchState
import com.pixelized.biblib.ui.scaffold.SearchFilter
import com.pixelized.biblib.ui.theme.BibLibTheme
import com.pixelized.biblib.utils.extention.default
@Composable
fun CategorySearchPage(
searchViewModel: SearchViewModel = hiltViewModel(),
focusRequester: FocusRequester = FocusRequester(),
filter: SearchFilter?,
onClose: () -> Unit = default(),
) {
val bottomSearchState = LocalBottomSearchState.current
CategorySearchPageContent(
focusRequester = focusRequester,
filter = filter,
onClose = onClose,
searchValue = {
when (bottomSearchState.filter) {
is SearchFilter.Author -> searchViewModel.author
is SearchFilter.Genre -> searchViewModel.genre
is SearchFilter.Language -> searchViewModel.language
null -> ""
}
},
onSearchChange = {
when (bottomSearchState.filter) {
is SearchFilter.Author -> searchViewModel.filterAuthor(it)
is SearchFilter.Genre -> searchViewModel.filterGenre(it)
is SearchFilter.Language -> searchViewModel.filterLanguage(it)
null -> Unit
}
},
)
}
@Composable
fun CategorySearchPageContent(
focusRequester: FocusRequester = FocusRequester(),
filter: SearchFilter?,
searchValue: () -> String,
onSearchChange: (String) -> Unit = default<String>(),
onClose: () -> Unit = default(),
) {
Column(
modifier = Modifier
.imePadding()
.fillMaxSize()
) {
TopAppBar(
backgroundColor = MaterialTheme.colors.surface,
elevation = 0.dp,
title = {
filter?.let {
Text(
style = MaterialTheme.typography.h6,
color = MaterialTheme.colors.onSurface,
text = stringResource(id = it.label),
)
}
},
navigationIcon = {
IconButton(onClick = onClose) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = null,
)
}
}
)
TextField(
modifier = Modifier
.focusRequester(focusRequester = focusRequester)
.fillMaxWidth(),
label = {
Text(
color = MaterialTheme.colors.onSurface,
text = "Rechercher"
)
},
value = searchValue(),
singleLine = true,
colors = TextFieldDefaults.outlinedTextFieldColors(
focusedBorderColor = Color.Transparent,
unfocusedBorderColor = Color.Transparent,
disabledBorderColor = Color.Transparent,
),
onValueChange = onSearchChange
)
}
}
@Composable
@Preview(showBackground = true, uiMode = UI_MODE_NIGHT_NO)
@Preview(showBackground = true, uiMode = UI_MODE_NIGHT_YES)
private fun CategorySearchPageContentPreview() {
BibLibTheme {
CategorySearchPageContent(
filter = SearchFilter.Author(),
searchValue = { "Asimov" },
)
}
}

View file

@ -13,22 +13,49 @@ 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.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import com.pixelized.biblib.ui.scaffold.LocalBottomSearchState
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
@Composable
fun SearchPage() {
fun SearchPage(
searchViewModel: SearchViewModel = LocalSearchViewModel.current
) {
val bottomSearchState = LocalBottomSearchState.current
val filters by remember {
derivedStateOf {
listOf(
SearchFilter.Author(value = searchViewModel.author),
SearchFilter.Genre(value = searchViewModel.genre),
SearchFilter.Language(value = searchViewModel.language),
)
}
}
SearchPageContent(
modifier = Modifier.fillMaxWidth()
modifier = Modifier.fillMaxWidth(),
filters = filters,
onFilter = {
bottomSearchState.expandSearch(it)
}
)
}
@Composable
private fun SearchPageContent(
modifier: Modifier = Modifier,
filters: List<SearchFilter> = SearchFilter.all,
onFilter: (filter: SearchFilter) -> Unit = default<SearchFilter>(),
) {
LazyColumn(
modifier = modifier,
@ -36,7 +63,9 @@ private fun SearchPageContent(
) {
item(key = "Search Filter") {
SearchFilter(
modifier = Modifier.padding(horizontal = MaterialTheme.bibLib.dimen.dp16)
modifier = Modifier.padding(horizontal = MaterialTheme.bibLib.dimen.dp16),
filters = filters,
onFilter = onFilter,
)
}
}
@ -45,24 +74,28 @@ private fun SearchPageContent(
@Composable
private fun SearchFilter(
modifier: Modifier = Modifier,
filters: List<SearchFilter> = SearchFilter.all,
onFilter: (filter: SearchFilter) -> Unit = default<SearchFilter>(),
) {
Row(
modifier = Modifier
.horizontalScroll(rememberScrollState())
.then(modifier)
) {
SearchChipFilter(
label = "Autheur",
selected = true,
value = "Isaac Asimov"
)
SearchChipFilter(
modifier = Modifier.padding(horizontal = MaterialTheme.bibLib.dimen.dp8),
label = "Genre",
)
SearchChipFilter(
label = "Langue",
)
filters.forEachIndexed { index, filter ->
val modifier = if (index != filters.lastIndex) {
Modifier.padding(end = MaterialTheme.bibLib.dimen.dp8)
} else {
Modifier
}
SearchChipFilter(
modifier = modifier,
selected = filter.isSelected,
label = stringResource(id = filter.label),
value = filter.value,
onClick = { onFilter(filter) }
)
}
}
}
@ -97,8 +130,8 @@ private fun SearchChipFilter(
}
@Composable
@Preview(uiMode = UI_MODE_NIGHT_NO)
@Preview(uiMode = UI_MODE_NIGHT_YES)
@Preview(showBackground = true, uiMode = UI_MODE_NIGHT_NO)
@Preview(showBackground = true, uiMode = UI_MODE_NIGHT_YES)
private fun SearchPageContentPreview() {
BibLibTheme {
SearchPageContent()

View file

@ -3,9 +3,14 @@ package com.pixelized.biblib.utils.extention
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.statusBars
import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.Dp
@Composable
fun navigationBarsHeight(): Dp =
WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
@Composable
fun statusBarsHeight(): Dp =
WindowInsets.statusBars.asPaddingValues().calculateTopPadding()

View file

@ -41,4 +41,8 @@
<string name="detail_series">Séries</string>
<string name="detail_emails_title">Envoyer cet eBook à :</string>
<string name="search_filter_author">Auteur</string>
<string name="search_filter_genre">Genre</string>
<string name="search_filter_language">Langue</string>
</resources>

View file

@ -1,6 +1,7 @@
<resources>
<string name="app_name" translatable="false">BibLibrary</string>
<string name="app_version" translatable="false">%1$s: %2$s - %3$d</string>
<string name="not_implemented_yet" translatable="false">Not implemented yet.</string>
<!-- Actions -->
@ -46,5 +47,8 @@
<string name="detail_series">Series</string>
<string name="detail_emails_title">Send this eBook to:</string>
<string name="not_implemented_yet" translatable="false">Not implemented yet.</string>
<string name="search_filter_author">Author</string>
<string name="search_filter_genre">Genre</string>
<string name="search_filter_language">Language</string>
</resources>