Sorting feature.

This commit is contained in:
Thomas Andres Gomez 2023-03-29 16:05:29 +02:00
parent c3bbaa15f3
commit bb056c55e7
15 changed files with 679 additions and 184 deletions

View file

@ -133,6 +133,9 @@ dependencies {
implementation "com.github.skydoves:landscapist-glide:1.5.2"
kapt 'com.github.bumptech.glide:compiler:4.13.2' // this have to be align with landscapist-glide
// Reorder element in laylist
implementation "org.burnoutcrew.composereorderable:reorderable:0.9.6"
// Navigation
implementation "androidx.navigation:navigation-compose:2.5.3"

View file

@ -6,6 +6,14 @@ import androidx.compose.runtime.Stable
@Stable
@Immutable
enum class SortType {
Book,
New,
Title,
Series,
}
@Stable
@Immutable
enum class SortValue {
ASC,
DESC,
}

View file

@ -50,8 +50,8 @@ class BookRepository constructor(
return bookFactory.fromDetailResponseToBook(response)
}
override suspend fun update(forceRefresh:Boolean) = withContext(Dispatchers.IO) {
if (getBookCount() <= 0 || fetchNewBooks() || forceRefresh) {
override suspend fun update() = withContext(Dispatchers.IO) {
if (fetchNewBooks() || getBookCount() <= 0) {
fetchAllBooks()
}
}

View file

@ -5,7 +5,7 @@ import com.pixelized.biblib.model.book.*
interface IBookRepository {
suspend fun update(forceRefresh: Boolean = false)
suspend fun update()
suspend fun getBookDetail(id: Int): Book

View file

@ -2,6 +2,7 @@ package com.pixelized.biblib.repository.search
import com.pixelized.biblib.model.book.*
import com.pixelized.biblib.model.search.SortType
import com.pixelized.biblib.model.search.SortValue
interface ISearchRepository {
@ -12,7 +13,7 @@ interface ISearchRepository {
seriesId: Int?,
genreId: Int?,
languageId: Int?,
sortBy : SortType,
sortBy: Map<SortType, SortValue?>,
limit: Int,
offset: Int,
): List<Book>

View file

@ -8,6 +8,7 @@ import com.pixelized.biblib.database.data.*
import com.pixelized.biblib.database.factory.*
import com.pixelized.biblib.model.book.*
import com.pixelized.biblib.model.search.SortType
import com.pixelized.biblib.model.search.SortValue
class SearchRepository constructor(
private val database: BibLibDatabase
@ -20,12 +21,13 @@ class SearchRepository constructor(
seriesId: Int?,
genreId: Int?,
languageId: Int?,
sortBy: SortType,
sortBy: Map<SortType, SortValue?>,
limit: Int,
offset: Int
): List<Book> {
// build an argument list for the SimpleSQLQuery.
val args = mutableListOf<Any>()
val sort = mutableListOf<Any>()
// build the core of the SQL query.
var query = "SELECT ${BookDbo.TABLE}.* FROM ${BookDbo.TABLE}"
// add where arguments.
@ -47,9 +49,14 @@ class SearchRepository constructor(
query += args.where(argument = languageId) {
BookDbo.run { "$TABLE.$LANGUAGE_ID LIKE ?" }
}
query += when (sortBy) {
SortType.Book -> BookDbo.run { " ORDER BY $TABLE.$SORT" }
SortType.Series -> BookDbo.run { " ORDER BY $TABLE.$SERIES_INDEX" }
sortBy.entries.forEach { (type: SortType, value: SortValue?) ->
query += sort.sort(argument = value) {
when (type) {
SortType.New -> BookDbo.run { "$TABLE.$NEW_ORDER" }
SortType.Title -> BookDbo.run { "$TABLE.$SORT" }
SortType.Series -> BookDbo.run { "$TABLE.$SERIES_INDEX" }
}
}
}
// Limit and Offset the query.
query += " LIMIT $limit OFFSET $offset;"
@ -135,4 +142,22 @@ private fun MutableList<Any>.where(
} else {
""
}
}
private fun MutableList<Any>.sort(
argument: SortValue?,
block: () -> String,
): String {
return if (argument != null) {
val prefix = if (isEmpty()) "ORDER BY" else ","
add(argument)
" $prefix ${block()} ${argument.order()}"
} else {
""
}
}
private fun SortValue.order(): String = when (this) {
SortValue.ASC -> "ASC"
SortValue.DESC -> "DESC"
}

View file

@ -46,17 +46,17 @@ class FilterBottomSheetState(
var type: FilterType by mutableStateOf(value = initialSheet)
private set
var currentALOBottomSheetData by mutableStateOf<FilterBottomSheetData?>(value = null)
var currentBottomSheetData by mutableStateOf<FilterBottomSheetData?>(value = null)
private set
suspend fun show(type: FilterType): FilterBottomSheetResult = mutex.withLock {
try {
return suspendCancellableCoroutine { continuation ->
this.type = type
currentALOBottomSheetData = FilterBottomSheetDataImpl(continuation)
currentBottomSheetData = FilterBottomSheetDataImpl(continuation)
}
} finally {
currentALOBottomSheetData = null
currentBottomSheetData = null
}
}
@ -110,11 +110,11 @@ fun FilterBottomSheet(
) {
val keyboard = LocalSoftwareKeyboardController.current
val focusManager: FocusManager = LocalFocusManager.current
val currentALOBottomSheetData = sheetState.currentALOBottomSheetData
val currentBottomSheetData = sheetState.currentBottomSheetData
// Check the state of the currentALOBottomSheetData and show / hide the bottomSheet accordingly.
LaunchedEffect(currentALOBottomSheetData) {
when (currentALOBottomSheetData) {
LaunchedEffect(currentBottomSheetData) {
when (currentBottomSheetData) {
null -> {
bottomSheetState.hide()
focusManager.clearFocus(force = true)
@ -127,12 +127,12 @@ fun FilterBottomSheet(
}
if (bottomSheetState.currentValue == Expanded && bottomSheetState.targetValue == Hidden) {
currentALOBottomSheetData?.dismiss()
currentBottomSheetData?.dismiss()
}
BackHandler(
enabled = bottomSheetState.isVisible,
onBack = { currentALOBottomSheetData?.dismiss() },
onBack = { currentBottomSheetData?.dismiss() },
)
ModalBottomSheetLayout(
@ -155,18 +155,18 @@ fun FilterBottomSheet(
viewModel = viewModel,
focusRequester = focusRequester,
onFilter = {
currentALOBottomSheetData?.performAction(filter = it)
currentBottomSheetData?.performAction(filter = it)
},
onClose = {
currentALOBottomSheetData?.dismiss()
currentBottomSheetData?.dismiss()
},
onIMEDone = {
currentALOBottomSheetData?.dismiss()
currentBottomSheetData?.dismiss()
}
)
LaunchedEffect(key1 = "FilterPageFocusRequest-${sheetState.type}") {
if (currentALOBottomSheetData != null) {
if (currentBottomSheetData != null) {
focusRequester.requestFocus()
keyboard?.show()
}

View file

@ -0,0 +1,125 @@
package com.pixelized.biblib.ui.composable.scaffold
import androidx.activity.compose.BackHandler
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.ModalBottomSheetLayout
import androidx.compose.material.ModalBottomSheetState
import androidx.compose.material.ModalBottomSheetValue.Expanded
import androidx.compose.material.ModalBottomSheetValue.Hidden
import androidx.compose.material.rememberModalBottomSheetState
import androidx.compose.runtime.*
import androidx.compose.ui.graphics.Color
import com.pixelized.biblib.model.search.SortType
import com.pixelized.biblib.model.search.SortValue
import com.pixelized.biblib.ui.screen.home.sort.BookSortPage
import kotlinx.coroutines.CancellableContinuation
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlin.coroutines.resume
val LocalSortBottomSheetState = staticCompositionLocalOf<SortBottomSheetState> {
error("LocalSortBottomSheetState not yet ready")
}
@Stable
class SortBottomSheetState {
private val mutex = Mutex()
var sortBy by mutableStateOf<Map<SortType, SortValue?>>(emptyMap())
private set
var currentBottomSheetData by mutableStateOf<SortBottomSheetData?>(value = null)
private set
suspend fun show(sortBy: Map<SortType, SortValue?>): SortBottomSheetResult = mutex.withLock {
try {
return suspendCancellableCoroutine { continuation ->
this.sortBy = sortBy
currentBottomSheetData = SortBottomSheetDataImpl(continuation)
}
} finally {
currentBottomSheetData = null
}
}
@Stable
private class SortBottomSheetDataImpl(
private val continuation: CancellableContinuation<SortBottomSheetResult>
) : SortBottomSheetData {
override fun performAction(sorting: Map<SortType, SortValue?>) {
if (continuation.isActive) continuation.resume(
SortBottomSheetResult.ActionPerformed(sortBy = sorting),
)
}
override fun dismiss() {
if (continuation.isActive) continuation.resume(
SortBottomSheetResult.Dismissed,
)
}
}
}
interface SortBottomSheetData {
fun performAction(sorting: Map<SortType, SortValue?>)
fun dismiss()
}
sealed class SortBottomSheetResult {
data class ActionPerformed(val sortBy: Map<SortType, SortValue?>) : SortBottomSheetResult()
object Dismissed : SortBottomSheetResult()
}
@Composable
fun rememberSortBottomSheetState(): SortBottomSheetState = remember {
SortBottomSheetState()
}
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun SortBottomSheet(
bottomSheetState: ModalBottomSheetState = rememberModalBottomSheetState(
initialValue = Hidden,
skipHalfExpanded = true,
),
sheetState: SortBottomSheetState = rememberSortBottomSheetState(),
content: @Composable () -> Unit,
) {
CompositionLocalProvider(
LocalSortBottomSheetState provides sheetState,
) {
val currentBottomSheetData = sheetState.currentBottomSheetData
// Check the state of the currentALOBottomSheetData and show / hide the bottomSheet accordingly.
LaunchedEffect(currentBottomSheetData) {
when (currentBottomSheetData) {
null -> bottomSheetState.hide()
else -> bottomSheetState.show()
}
}
if (bottomSheetState.currentValue == Expanded && bottomSheetState.targetValue == Hidden) {
currentBottomSheetData?.dismiss()
}
BackHandler(
enabled = bottomSheetState.isVisible,
onBack = { currentBottomSheetData?.dismiss() },
)
ModalBottomSheetLayout(
sheetState = bottomSheetState,
content = content,
scrimColor = Color.Transparent,
sheetContent = {
BookSortPage(
initial = sheetState.sortBy,
onConfirm = {
currentBottomSheetData?.performAction(it)
}
)
},
)
}
}

View file

@ -5,6 +5,7 @@ import androidx.paging.PagingSource
import androidx.paging.PagingState
import com.pixelized.biblib.model.book.Book
import com.pixelized.biblib.model.search.SortType
import com.pixelized.biblib.model.search.SortValue
import com.pixelized.biblib.repository.search.ISearchRepository
import com.pixelized.biblib.utils.extention.page
@ -15,7 +16,7 @@ class BookSearchSource(
private val authorId: Int?,
private val seriesId: Int?,
private val genreId: Int?,
private val sortBy: SortType,
private val sortBy: Map<SortType, SortValue?>,
private val languageId: Int?,
private val limit: Int,
) : PagingSource<Int, Book>() {

View file

@ -10,6 +10,9 @@ import com.pixelized.biblib.R
import com.pixelized.biblib.model.book.Book
import com.pixelized.biblib.model.search.FilterType
import com.pixelized.biblib.model.search.SortType
import com.pixelized.biblib.model.search.SortValue
import com.pixelized.biblib.repository.book.BookRepository
import com.pixelized.biblib.repository.book.IBookRepository
import com.pixelized.biblib.repository.search.ISearchRepository
import com.pixelized.biblib.ui.screen.home.common.item.LargeBookThumbnailUio
import com.pixelized.biblib.ui.screen.home.common.item.MicroBookThumbnailUio
@ -22,6 +25,7 @@ import com.pixelized.biblib.utils.extention.toSmallThumbnailUio
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.plus
import javax.inject.Inject
@ -30,7 +34,15 @@ import javax.inject.Inject
class BookSearchViewModel @Inject constructor(
application: Application,
private val searchRepository: ISearchRepository,
private val bookRepository: IBookRepository,
) : AndroidViewModel(application) {
private val _error = MutableSharedFlow<HomeScreenErrorUio.LibraryUpdate>()
val error: Flow<HomeScreenErrorUio.LibraryUpdate> = _error
private val _updating = mutableStateOf(false)
val updating: State<Boolean> = _updating
private var searchSource: BookSearchSource? = null
val microPaging: Flow<PagingData<MicroBookThumbnailUio>>
val smallPaging: Flow<PagingData<SmallBookThumbnailUio>>
@ -39,67 +51,22 @@ class BookSearchViewModel @Inject constructor(
private val _search = mutableStateOf<String?>(null)
val search: State<String?> = _search
private val new = mutableStateOf(
FilterChipUio(
id = FilterType.New,
title = stringResource(R.string.search_filter_new),
label = null,
selected = true,
closable = false,
openable = false,
)
)
var sortBy: Map<SortType, SortValue?> = mapOf(SortType.New to SortValue.ASC)
private set
private val newFilter = mutableNewFilterState()
private var filterByNew: Boolean? = true
private val author = mutableStateOf(
FilterChipUio(
id = FilterType.Author,
title = stringResource(R.string.search_filter_author),
label = null,
selected = false,
closable = false,
openable = true,
)
)
private val authorFilter = mutableAuthorFilterState()
private var filterByAuthorId: Int? = null
private val series = mutableStateOf(
FilterChipUio(
id = FilterType.Series,
title = stringResource(R.string.search_filter_series),
label = null,
selected = false,
closable = false,
openable = true,
)
)
private val seriesFilter = mutableSeriesFilterState()
private var filterBySeriesId: Int? = null
private val genre = mutableStateOf(
FilterChipUio(
id = FilterType.Genre,
title = stringResource(R.string.search_filter_genre),
label = null,
selected = false,
closable = false,
openable = true,
)
)
private val genreFilter = mutableGenreFilterState()
private var filterByGenreId: Int? = null
private val language = mutableStateOf(
FilterChipUio(
id = FilterType.Language,
title = stringResource(R.string.search_filter_language),
label = null,
selected = false,
closable = false,
openable = true,
)
)
private val languageFilter = mutableLanguageFilterState()
private var filterByLanguageId: Int? = null
val filters: List<State<FilterChipUio>> = listOf(new, author, series, genre, language)
val filters: List<State<FilterChipUio>> = listOf(
newFilter, authorFilter, seriesFilter, genreFilter, languageFilter
)
init {
val searchFlow = Pager(
@ -121,6 +88,28 @@ class BookSearchViewModel @Inject constructor(
.cachedIn(viewModelScope + Dispatchers.IO)
}
suspend fun updateLibrary() {
try {
_updating.value = true
bookRepository.update()
searchSource?.invalidate()
} catch (exception: Exception) {
_error.emit(
HomeScreenErrorUio.LibraryUpdate(
message = R.string.error_book_update_message,
action = R.string.error_book_update_action,
)
)
} finally {
_updating.value = false
}
}
fun sort(sortBy: Map<SortType, SortValue?>) {
this.sortBy = sortBy
searchSource?.invalidate()
}
fun filterSearch(criteria: String?) {
_search.value = criteria
searchSource?.invalidate()
@ -129,7 +118,7 @@ class BookSearchViewModel @Inject constructor(
fun filterByNew(criteria: Boolean) {
if (filterByNew != criteria) {
filterByNew = if (criteria) true else null
new.value = new.value.copy(selected = criteria)
newFilter.value = newFilter.value.copy(selected = criteria)
searchSource?.invalidate()
}
}
@ -137,10 +126,20 @@ class BookSearchViewModel @Inject constructor(
fun filterByAuthor(label: String?, criteria: Int?) {
if (filterByAuthorId != criteria) {
filterByAuthorId = criteria
author.value = if (criteria != null) {
author.value.copy(selected = true, label = label, closable = true, openable = false)
authorFilter.value = if (criteria != null) {
authorFilter.value.copy(
selected = true,
label = label,
closable = true,
openable = false
)
} else {
author.value.copy(selected = false, label = null, closable = false, openable = true)
authorFilter.value.copy(
selected = false,
label = null,
closable = false,
openable = true
)
}
searchSource?.invalidate()
}
@ -149,10 +148,20 @@ class BookSearchViewModel @Inject constructor(
fun filterBySeries(label: String?, criteria: Int?) {
if (filterBySeriesId != criteria) {
filterBySeriesId = criteria
series.value = if (criteria != null) {
series.value.copy(selected = true, label = label, closable = true, openable = false)
seriesFilter.value = if (criteria != null) {
seriesFilter.value.copy(
selected = true,
label = label,
closable = true,
openable = false
)
} else {
series.value.copy(selected = false, label = null, closable = false, openable = true)
seriesFilter.value.copy(
selected = false,
label = null,
closable = false,
openable = true
)
}
searchSource?.invalidate()
}
@ -161,10 +170,20 @@ class BookSearchViewModel @Inject constructor(
fun filterByGenre(label: String?, criteria: Int?) {
if (filterByGenreId != criteria) {
filterByGenreId = criteria
genre.value = if (criteria != null) {
genre.value.copy(selected = true, label = label, closable = true, openable = false)
genreFilter.value = if (criteria != null) {
genreFilter.value.copy(
selected = true,
label = label,
closable = true,
openable = false
)
} else {
genre.value.copy(selected = false, label = null, closable = false, openable = true)
genreFilter.value.copy(
selected = false,
label = null,
closable = false,
openable = true
)
}
searchSource?.invalidate()
}
@ -173,15 +192,15 @@ class BookSearchViewModel @Inject constructor(
fun filterByLanguage(label: String?, criteria: Int?) {
if (filterByLanguageId != criteria) {
filterByLanguageId = criteria
language.value = if (criteria != null) {
language.value.copy(
languageFilter.value = if (criteria != null) {
languageFilter.value.copy(
selected = true,
label = label,
closable = true,
openable = false
)
} else {
language.value.copy(
languageFilter.value.copy(
selected = false,
label = null,
closable = false,
@ -201,13 +220,68 @@ class BookSearchViewModel @Inject constructor(
seriesId = filterBySeriesId,
genreId = filterByGenreId,
languageId = filterByLanguageId,
sortBy = SortType.Book,
sortBy = sortBy,
limit = SEARCH_PAGE_SIZE,
).also {
searchSource = it
}
}
private fun mutableNewFilterState() = mutableStateOf(
FilterChipUio(
id = FilterType.New,
title = stringResource(R.string.search_filter_new),
label = null,
selected = true,
closable = false,
openable = false,
)
)
private fun mutableAuthorFilterState() = mutableStateOf(
FilterChipUio(
id = FilterType.Author,
title = stringResource(R.string.search_filter_author),
label = null,
selected = false,
closable = false,
openable = true,
)
)
private fun mutableSeriesFilterState() = mutableStateOf(
FilterChipUio(
id = FilterType.Series,
title = stringResource(R.string.search_filter_series),
label = null,
selected = false,
closable = false,
openable = true,
)
)
private fun mutableGenreFilterState() = mutableStateOf(
FilterChipUio(
id = FilterType.Genre,
title = stringResource(R.string.search_filter_genre),
label = null,
selected = false,
closable = false,
openable = true,
)
)
private fun mutableLanguageFilterState() = mutableStateOf(
FilterChipUio(
id = FilterType.Language,
title = stringResource(R.string.search_filter_language),
label = null,
selected = false,
closable = false,
openable = true,
)
)
companion object {
private const val SEARCH_PAGE_SIZE = 20
}

View file

@ -1,39 +0,0 @@
package com.pixelized.biblib.ui.screen.home
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import com.pixelized.biblib.R
import com.pixelized.biblib.repository.book.IBookRepository
import com.pixelized.biblib.ui.screen.home.HomeScreenErrorUio.LibraryUpdate
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import javax.inject.Inject
@HiltViewModel
class BookViewModel @Inject constructor(
private val bookRepository: IBookRepository,
) : ViewModel() {
private val _error = MutableSharedFlow<LibraryUpdate>()
val error: Flow<LibraryUpdate> = _error
private val _updating = mutableStateOf(false)
val updating: State<Boolean> = _updating
suspend fun updateLibrary() {
try {
_updating.value = true
bookRepository.update(forceRefresh = true)
} catch (exception: Exception) {
_error.emit(
LibraryUpdate(
message = R.string.error_book_update_message,
action = R.string.error_book_update_action,
)
)
} finally {
_updating.value = false
}
}
}

View file

@ -73,9 +73,8 @@ sealed class HomeScreenErrorUio(
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun HomeScreen(
bookViewModel: BookViewModel = hiltViewModel(),
bookViewModel: BookSearchViewModel = hiltViewModel(),
profileViewModel: HomeViewModel = hiltViewModel(),
filterViewModel: BookSearchViewModel = hiltViewModel(),
optionsViewModel: OptionsViewModel = hiltViewModel(),
) {
val context = LocalContext.current
@ -83,6 +82,7 @@ fun HomeScreen(
val snackBarHost = LocalSnackHostState.current
val detailState: DetailBottomSheetState = rememberDetailBottomSheetState()
val filterState: FilterBottomSheetState = rememberFilterBottomSheetState()
val sortingState: SortBottomSheetState = rememberSortBottomSheetState()
val scope = rememberCoroutineScope()
val largeGridState = rememberLazyGridState()
val smallListState = rememberLazyListState()
@ -91,58 +91,74 @@ fun HomeScreen(
FilterBottomSheet(
sheetState = filterState,
) {
DetailBottomSheet(
bottomDetailState = detailState,
SortBottomSheet(
sheetState = sortingState,
) {
HomeScreenContent(
modifier = Modifier.systemBarsPadding(),
avatar = profileViewModel.avatar,
search = filterViewModel.search,
onSearch = {
filterViewModel.filterSearch(criteria = it)
},
onAvatarTap = {
navigation.navigateToProfile()
},
filters = filterViewModel.filters,
onFilter = rememberOnFilter(
filterViewModel = filterViewModel,
filterState = filterState,
),
options = optionsViewModel.options,
onUp = {
scope.launch {
launch { largeGridState.animateScrollToItem(index = 0) }
launch { smallListState.animateScrollToItem(index = 0) }
launch { microListState.animateScrollToItem(index = 0) }
}
},
onSort = { },
onMicro = optionsViewModel::onMicro,
onSmall = optionsViewModel::onSmall,
onLarge = optionsViewModel::onLarge,
isLoading = bookViewModel.updating,
onPullToRefresh = {
scope.launch {
bookViewModel.updateLibrary()
}
},
largeGridState = largeGridState,
largeGrid = filterViewModel.largePaging,
smallListState = smallListState,
smallList = filterViewModel.smallPaging,
microListState = microListState,
microList = filterViewModel.microPaging,
onBook = {
scope.launch {
detailState.expandBookDetail(id = it)
}
},
)
DetailBottomSheet(
bottomDetailState = detailState,
) {
HomeScreenContent(
modifier = Modifier.systemBarsPadding(),
avatar = profileViewModel.avatar,
search = bookViewModel.search,
onSearch = {
bookViewModel.filterSearch(criteria = it)
},
onAvatarTap = {
navigation.navigateToProfile()
},
filters = bookViewModel.filters,
onFilter = rememberOnFilter(
filterViewModel = bookViewModel,
filterState = filterState,
),
options = optionsViewModel.options,
onUp = {
scope.launch {
launch { largeGridState.animateScrollToItem(index = 0) }
launch { smallListState.animateScrollToItem(index = 0) }
launch { microListState.animateScrollToItem(index = 0) }
}
},
onSort = {
scope.launch {
val result = sortingState.show(sortBy = bookViewModel.sortBy)
if (result is SortBottomSheetResult.ActionPerformed) {
bookViewModel.sort(sortBy = result.sortBy)
}
}
},
onMicro = optionsViewModel::onMicro,
onSmall = optionsViewModel::onSmall,
onLarge = optionsViewModel::onLarge,
isLoading = bookViewModel.updating,
onPullToRefresh = {
scope.launch {
bookViewModel.updateLibrary()
}
},
largeGridState = largeGridState,
largeGrid = bookViewModel.largePaging,
smallListState = smallListState,
smallList = bookViewModel.smallPaging,
microListState = microListState,
microList = bookViewModel.microPaging,
onBook = {
scope.launch {
detailState.expandBookDetail(id = it)
}
},
)
}
}
}
LaunchedEffect(key1 = "HomeScreenErrorManagement") {
LaunchedEffect(key1 = "HomeScreenLaunchedEffect") {
// update
launch {
bookViewModel.updateLibrary()
}
// error management.
launch {
bookViewModel.error.collect {
val result = snackBarHost.showSnackbar(
@ -292,9 +308,11 @@ private fun HomeScreenContent(
)
},
loader = {
Box(modifier = Modifier
.padding(horizontal = 16.dp)
.height(1.dp)) {
Box(
modifier = Modifier
.padding(horizontal = 16.dp)
.height(1.dp)
) {
Divider()
if (isLoading.value) {
LinearProgressIndicator(

View file

@ -0,0 +1,209 @@
package com.pixelized.biblib.ui.screen.home.sort
import android.content.res.Configuration
import androidx.annotation.StringRes
import androidx.compose.animation.*
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowUpward
import androidx.compose.material.icons.filled.Menu
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.draw.shadow
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.R
import com.pixelized.biblib.model.search.SortType
import com.pixelized.biblib.model.search.SortValue
import com.pixelized.biblib.ui.theme.BibLibTheme
import com.pixelized.biblib.utils.extention.bibLib
import org.burnoutcrew.reorderable.*
@Stable
@Immutable
data class SortUio(
val id: SortType,
@StringRes val label: Int,
val ascending: Boolean? = null,
)
@Composable
fun BookSortPage(
viewModel: BookSortViewModel = hiltViewModel(),
initial: Map<SortType, SortValue?>,
onConfirm: (Map<SortType, SortValue?>) -> Unit,
) {
LaunchedEffect(key1 = initial) {
viewModel.update(initial)
}
BookSortPageContent(
modifier = Modifier
.fillMaxWidth()
.navigationBarsPadding()
.padding(vertical = 16.dp),
state = rememberReorderableLazyListState(onMove = viewModel::onMove),
items = viewModel.items,
onSort = viewModel::onSort,
onConfirm = { onConfirm(viewModel.onConfirm()) }
)
}
@Composable
private fun BookSortPageContent(
modifier: Modifier = Modifier,
state: ReorderableLazyListState,
items: State<List<SortUio>>,
onSort: (SortUio) -> Unit,
onConfirm: () -> Unit,
) {
val elevation: ElevationOverlay? = LocalElevationOverlay.current
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Text(
modifier = Modifier.padding(horizontal = 16.dp),
style = MaterialTheme.typography.h6,
color = MaterialTheme.bibLib.colors.typography.medium,
text = "Sort by:"
)
LazyColumn(
modifier = Modifier.reorderable(state),
state = state.listState,
) {
items(items = items.value, key = { it.id }) {
ReorderableItem(state, key = it.id) { isDragging ->
val shadow = animateDpAsState(if (isDragging) 16.dp else 0.dp)
val background = elevation?.apply(
color = MaterialTheme.colors.surface,
elevation = LocalAbsoluteElevation.current + shadow.value,
) ?: MaterialTheme.colors.surface
SortPageItem(
modifier = Modifier
.fillParentMaxWidth()
.height(height = 52.dp)
.shadow(elevation = shadow.value)
.background(color = background)
.clickable { onSort(it) }
.padding(horizontal = 16.dp),
state = state,
label = stringResource(id = it.label),
ascending = it.ascending,
)
}
}
}
Button(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
onClick = onConfirm
) {
Text(text = "Sort")
}
}
}
@Composable
private fun SortPageItem(
modifier: Modifier = Modifier,
state: ReorderableLazyListState,
label: String,
ascending: Boolean?,
) {
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
Text(
style = MaterialTheme.typography.body1,
color = MaterialTheme.bibLib.colors.typography.medium,
text = label
)
AnimatedVisibility(
visible = ascending != null,
enter = fadeIn() + slideInVertically { it / 2 },
exit = fadeOut() + slideOutVertically { it / 2 },
) {
val angle by animateFloatAsState(
targetValue = when (ascending) {
true -> 0f
else -> 180f
}
)
Icon(
modifier = Modifier.rotate(degrees = angle),
imageVector = Icons.Default.ArrowUpward,
tint = MaterialTheme.bibLib.colors.typography.medium,
contentDescription = null,
)
}
}
Icon(
modifier = Modifier.detectReorderAfterLongPress(state),
imageVector = Icons.Default.Menu,
tint = MaterialTheme.bibLib.colors.typography.medium,
contentDescription = null,
)
}
}
@Composable
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_NO)
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
private fun BookSortPagePreview() {
BibLibTheme {
val items = remember {
mutableStateOf(
listOf(
SortUio(
id = SortType.New,
label = R.string.search_sort_new,
ascending = true,
),
SortUio(
id = SortType.Title,
label = R.string.search_sort_title,
ascending = false,
),
SortUio(
id = SortType.Series,
label = R.string.search_sort_series,
ascending = null,
),
)
)
}
BookSortPageContent(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 16.dp),
state = rememberReorderableLazyListState(onMove = { _, _ -> }),
items = items,
onSort = { },
onConfirm = { },
)
}
}

View file

@ -0,0 +1,65 @@
package com.pixelized.biblib.ui.screen.home.sort
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import com.pixelized.biblib.R
import com.pixelized.biblib.model.search.SortType
import com.pixelized.biblib.model.search.SortValue
import dagger.hilt.android.lifecycle.HiltViewModel
import org.burnoutcrew.reorderable.ItemPosition
import javax.inject.Inject
@HiltViewModel
class BookSortViewModel @Inject constructor() : ViewModel() {
val items = mutableStateOf(
listOf(
SortUio(
id = SortType.New,
label = R.string.search_sort_new,
ascending = true,
),
SortUio(
id = SortType.Title,
label = R.string.search_sort_title,
ascending = null,
),
SortUio(
id = SortType.Series,
label = R.string.search_sort_series,
ascending = null,
),
)
)
fun update(sortBy: Map<SortType, SortValue?>) {
items.value = items.value.map { item ->
item.copy(ascending = sortBy[item.id]?.let { it == SortValue.ASC })
}
}
fun onSort(item: SortUio) {
val index = items.value.indexOf(item)
items.value = items.value.toMutableList().also {
it[index] = item.copy(
ascending = when (item.ascending) {
true -> false
false -> null
null -> true
}
)
}
}
fun onMove(from: ItemPosition, to: ItemPosition) {
items.value = items.value.toMutableList().apply {
add(to.index, removeAt(from.index))
}
}
fun onConfirm(): Map<SortType, SortValue?> {
val data: List<Pair<SortType, SortValue?>> = items.value.map { item ->
item.id to item.ascending?.let { if (it) SortValue.ASC else SortValue.DESC }
}
return mapOf(*data.toTypedArray())
}
}

View file

@ -85,6 +85,11 @@
<string name="search_filter_language">Languages</string>
<string name="search_sort_by">Sort by: %1$s</string>
<string name="search_sort_new">Last addition</string>
<string name="search_sort_title">Book title</string>
<string name="search_sort_series">Series order</string>
<string name="search_sort_asc_action">Ascending</string>
<string name="search_sort_dsc_action">Descending</string>
<string name="profile_title">Welcome</string>
<string name="profile_emails">Linked emails:</string>