diff --git a/app/build.gradle b/app/build.gradle index 3bf341c..5486a6b 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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" diff --git a/app/src/main/java/com/pixelized/biblib/model/search/SortType.kt b/app/src/main/java/com/pixelized/biblib/model/search/SortType.kt index 3a389d1..14cdcfb 100644 --- a/app/src/main/java/com/pixelized/biblib/model/search/SortType.kt +++ b/app/src/main/java/com/pixelized/biblib/model/search/SortType.kt @@ -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, } \ No newline at end of file diff --git a/app/src/main/java/com/pixelized/biblib/repository/book/BookRepository.kt b/app/src/main/java/com/pixelized/biblib/repository/book/BookRepository.kt index 3131db1..28928e1 100644 --- a/app/src/main/java/com/pixelized/biblib/repository/book/BookRepository.kt +++ b/app/src/main/java/com/pixelized/biblib/repository/book/BookRepository.kt @@ -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() } } diff --git a/app/src/main/java/com/pixelized/biblib/repository/book/IBookRepository.kt b/app/src/main/java/com/pixelized/biblib/repository/book/IBookRepository.kt index 687ee7b..ac4f668 100644 --- a/app/src/main/java/com/pixelized/biblib/repository/book/IBookRepository.kt +++ b/app/src/main/java/com/pixelized/biblib/repository/book/IBookRepository.kt @@ -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 diff --git a/app/src/main/java/com/pixelized/biblib/repository/search/ISearchRepository.kt b/app/src/main/java/com/pixelized/biblib/repository/search/ISearchRepository.kt index 8a2d852..9f522a2 100644 --- a/app/src/main/java/com/pixelized/biblib/repository/search/ISearchRepository.kt +++ b/app/src/main/java/com/pixelized/biblib/repository/search/ISearchRepository.kt @@ -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, limit: Int, offset: Int, ): List diff --git a/app/src/main/java/com/pixelized/biblib/repository/search/SearchRepository.kt b/app/src/main/java/com/pixelized/biblib/repository/search/SearchRepository.kt index 962de31..c3c90b5 100644 --- a/app/src/main/java/com/pixelized/biblib/repository/search/SearchRepository.kt +++ b/app/src/main/java/com/pixelized/biblib/repository/search/SearchRepository.kt @@ -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, limit: Int, offset: Int ): List { // build an argument list for the SimpleSQLQuery. val args = mutableListOf() + val sort = mutableListOf() // 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.where( } else { "" } +} + +private fun MutableList.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" } \ No newline at end of file diff --git a/app/src/main/java/com/pixelized/biblib/ui/composable/scaffold/FilterBottomSheet.kt b/app/src/main/java/com/pixelized/biblib/ui/composable/scaffold/FilterBottomSheet.kt index a65ad50..5ba7e52 100644 --- a/app/src/main/java/com/pixelized/biblib/ui/composable/scaffold/FilterBottomSheet.kt +++ b/app/src/main/java/com/pixelized/biblib/ui/composable/scaffold/FilterBottomSheet.kt @@ -46,17 +46,17 @@ class FilterBottomSheetState( var type: FilterType by mutableStateOf(value = initialSheet) private set - var currentALOBottomSheetData by mutableStateOf(value = null) + var currentBottomSheetData by mutableStateOf(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() } diff --git a/app/src/main/java/com/pixelized/biblib/ui/composable/scaffold/SortBottomSheet.kt b/app/src/main/java/com/pixelized/biblib/ui/composable/scaffold/SortBottomSheet.kt new file mode 100644 index 0000000..6691e56 --- /dev/null +++ b/app/src/main/java/com/pixelized/biblib/ui/composable/scaffold/SortBottomSheet.kt @@ -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 { + error("LocalSortBottomSheetState not yet ready") +} + +@Stable +class SortBottomSheetState { + private val mutex = Mutex() + + var sortBy by mutableStateOf>(emptyMap()) + private set + + var currentBottomSheetData by mutableStateOf(value = null) + private set + + suspend fun show(sortBy: Map): SortBottomSheetResult = mutex.withLock { + try { + return suspendCancellableCoroutine { continuation -> + this.sortBy = sortBy + currentBottomSheetData = SortBottomSheetDataImpl(continuation) + } + } finally { + currentBottomSheetData = null + } + } + + @Stable + private class SortBottomSheetDataImpl( + private val continuation: CancellableContinuation + ) : SortBottomSheetData { + override fun performAction(sorting: Map) { + 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) + fun dismiss() +} + +sealed class SortBottomSheetResult { + data class ActionPerformed(val sortBy: Map) : 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) + } + ) + }, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/pixelized/biblib/ui/screen/home/BookSearchSource.kt b/app/src/main/java/com/pixelized/biblib/ui/screen/home/BookSearchSource.kt index 1d1ccd5..f73ea69 100644 --- a/app/src/main/java/com/pixelized/biblib/ui/screen/home/BookSearchSource.kt +++ b/app/src/main/java/com/pixelized/biblib/ui/screen/home/BookSearchSource.kt @@ -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, private val languageId: Int?, private val limit: Int, ) : PagingSource() { diff --git a/app/src/main/java/com/pixelized/biblib/ui/screen/home/BookSearchViewModel.kt b/app/src/main/java/com/pixelized/biblib/ui/screen/home/BookSearchViewModel.kt index 8b7ba67..f978ab6 100644 --- a/app/src/main/java/com/pixelized/biblib/ui/screen/home/BookSearchViewModel.kt +++ b/app/src/main/java/com/pixelized/biblib/ui/screen/home/BookSearchViewModel.kt @@ -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() + val error: Flow = _error + + private val _updating = mutableStateOf(false) + val updating: State = _updating + private var searchSource: BookSearchSource? = null val microPaging: Flow> val smallPaging: Flow> @@ -39,67 +51,22 @@ class BookSearchViewModel @Inject constructor( private val _search = mutableStateOf(null) val search: State = _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 = 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> = listOf(new, author, series, genre, language) + val filters: List> = 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) { + 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 } diff --git a/app/src/main/java/com/pixelized/biblib/ui/screen/home/BookViewModel.kt b/app/src/main/java/com/pixelized/biblib/ui/screen/home/BookViewModel.kt deleted file mode 100644 index f8cea57..0000000 --- a/app/src/main/java/com/pixelized/biblib/ui/screen/home/BookViewModel.kt +++ /dev/null @@ -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() - val error: Flow = _error - - private val _updating = mutableStateOf(false) - val updating: State = _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 - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/pixelized/biblib/ui/screen/home/HomeScreen.kt b/app/src/main/java/com/pixelized/biblib/ui/screen/home/HomeScreen.kt index e9457e5..1d83391 100644 --- a/app/src/main/java/com/pixelized/biblib/ui/screen/home/HomeScreen.kt +++ b/app/src/main/java/com/pixelized/biblib/ui/screen/home/HomeScreen.kt @@ -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( diff --git a/app/src/main/java/com/pixelized/biblib/ui/screen/home/sort/BookSortPage.kt b/app/src/main/java/com/pixelized/biblib/ui/screen/home/sort/BookSortPage.kt new file mode 100644 index 0000000..bcc3190 --- /dev/null +++ b/app/src/main/java/com/pixelized/biblib/ui/screen/home/sort/BookSortPage.kt @@ -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, + onConfirm: (Map) -> 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>, + 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 = { }, + ) + } +} diff --git a/app/src/main/java/com/pixelized/biblib/ui/screen/home/sort/BookSortViewModel.kt b/app/src/main/java/com/pixelized/biblib/ui/screen/home/sort/BookSortViewModel.kt new file mode 100644 index 0000000..0a1c951 --- /dev/null +++ b/app/src/main/java/com/pixelized/biblib/ui/screen/home/sort/BookSortViewModel.kt @@ -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) { + 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 { + val data: List> = items.value.map { item -> + item.id to item.ascending?.let { if (it) SortValue.ASC else SortValue.DESC } + } + return mapOf(*data.toTypedArray()) + } +} \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 475cfe7..035edf5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -85,6 +85,11 @@ Languages Sort by: %1$s + Last addition + Book title + Series order + Ascending + Descending Welcome Linked emails: