From d50b1a5f9ac8f1c21356513997264a8c221e5c03 Mon Sep 17 00:00:00 2001 From: Acclorite <2ma0bhpk@anonaddy.com> Date: Thu, 14 Aug 2025 23:13:10 +0300 Subject: [PATCH] refactor: browse viewmodel --- .../use_case/file_system/GetFilesUseCase.kt | 72 +++ .../file_system/SearchFilesUseCase.kt | 33 -- .../presentation/browse/BrowseEffect.kt | 24 + .../presentation/browse/BrowseEvent.kt | 25 +- .../presentation/browse/BrowseModel.kt | 500 +++++++----------- .../presentation/browse/BrowseScreen.kt | 43 +- .../book_story/ui/browse/BrowseAddDialog.kt | 10 +- .../book_story/ui/browse/BrowseBackHandler.kt | 4 +- .../book_story/ui/browse/BrowseContent.kt | 11 +- .../book_story/ui/browse/BrowseDialog.kt | 6 +- .../book_story/ui/browse/BrowseEffects.kt | 57 ++ .../ui/browse/BrowseEmptyPlaceholder.kt | 5 +- .../book_story/ui/browse/BrowseScaffold.kt | 19 +- .../book_story/ui/browse/BrowseTopBar.kt | 3 +- 14 files changed, 398 insertions(+), 414 deletions(-) create mode 100644 app/src/main/java/ua/acclorite/book_story/domain/use_case/file_system/GetFilesUseCase.kt delete mode 100644 app/src/main/java/ua/acclorite/book_story/domain/use_case/file_system/SearchFilesUseCase.kt create mode 100644 app/src/main/java/ua/acclorite/book_story/presentation/browse/BrowseEffect.kt create mode 100644 app/src/main/java/ua/acclorite/book_story/ui/browse/BrowseEffects.kt diff --git a/app/src/main/java/ua/acclorite/book_story/domain/use_case/file_system/GetFilesUseCase.kt b/app/src/main/java/ua/acclorite/book_story/domain/use_case/file_system/GetFilesUseCase.kt new file mode 100644 index 00000000..c242a10a --- /dev/null +++ b/app/src/main/java/ua/acclorite/book_story/domain/use_case/file_system/GetFilesUseCase.kt @@ -0,0 +1,72 @@ +/* + * Book's Story — free and open-source Material You eBook reader. + * Copyright (C) 2024-2025 Acclorite + * SPDX-License-Identifier: GPL-3.0-only + */ + +package ua.acclorite.book_story.domain.use_case.file_system + +import ua.acclorite.book_story.core.helpers.compareByWithOrder +import ua.acclorite.book_story.core.log.logE +import ua.acclorite.book_story.core.log.logI +import ua.acclorite.book_story.data.settings.SettingsManager +import ua.acclorite.book_story.domain.model.file.File +import ua.acclorite.book_story.domain.repository.FileSystemRepository +import ua.acclorite.book_story.presentation.browse.model.BrowseSortOrder +import javax.inject.Inject + +class GetFilesUseCase @Inject constructor( + private val fileSystemRepository: FileSystemRepository, + private val settings: SettingsManager +) { + + suspend operator fun invoke(query: String = ""): List { + logI("Searching for files with query: \"$query\".") + + fun List.filterFiles(): List { + return if (settings.browseIncludedFilterItems.lastValue.isEmpty()) this + else filter { file -> + settings.browseIncludedFilterItems.lastValue.any { extension -> + file.path.endsWith( + extension, ignoreCase = true + ) + } + } + } + + fun List.sortFiles(): List { + return sortedWith( + compareByWithOrder(settings.browseSortOrderDescending.lastValue) { file -> + when (settings.browseSortOrder.lastValue) { + BrowseSortOrder.NAME -> file.name.trim() + + BrowseSortOrder.FILE_FORMAT -> file.path + .substringAfterLast(".") + .lowercase() + .trimEnd() + + BrowseSortOrder.FILE_SIZE -> file.size + + else -> file.lastModified + } + } + ) + } + + return runCatching { + fileSystemRepository.searchFiles(query) + .getOrThrow() + .filterFiles() + .sortFiles() + }.fold( + onSuccess = { + logI("Successfully found [${it.size}] files.") + it + }, + onFailure = { + logE("Could not find files with error: ${it.message}") + emptyList() + } + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/ua/acclorite/book_story/domain/use_case/file_system/SearchFilesUseCase.kt b/app/src/main/java/ua/acclorite/book_story/domain/use_case/file_system/SearchFilesUseCase.kt deleted file mode 100644 index f0181647..00000000 --- a/app/src/main/java/ua/acclorite/book_story/domain/use_case/file_system/SearchFilesUseCase.kt +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Book's Story — free and open-source Material You eBook reader. - * Copyright (C) 2024-2025 Acclorite - * SPDX-License-Identifier: GPL-3.0-only - */ - -package ua.acclorite.book_story.domain.use_case.file_system - -import ua.acclorite.book_story.core.log.logE -import ua.acclorite.book_story.core.log.logI -import ua.acclorite.book_story.domain.model.file.File -import ua.acclorite.book_story.domain.repository.FileSystemRepository -import javax.inject.Inject - -class SearchFilesUseCase @Inject constructor( - private val fileSystemRepository: FileSystemRepository -) { - - suspend operator fun invoke(query: String = ""): List { - logI("Searching for files with query: \"$query\".") - - return fileSystemRepository.searchFiles(query).fold( - onSuccess = { - logI("Successfully found [${it.size}] files.") - it - }, - onFailure = { - logE("Could not find files with error: ${it.message}") - emptyList() - } - ) - } -} \ No newline at end of file diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/browse/BrowseEffect.kt b/app/src/main/java/ua/acclorite/book_story/presentation/browse/BrowseEffect.kt new file mode 100644 index 00000000..4cd713c6 --- /dev/null +++ b/app/src/main/java/ua/acclorite/book_story/presentation/browse/BrowseEffect.kt @@ -0,0 +1,24 @@ +/* + * Book's Story — free and open-source Material You eBook reader. + * Copyright (C) 2024-2025 Acclorite + * SPDX-License-Identifier: GPL-3.0-only + */ + +package ua.acclorite.book_story.presentation.browse + +import androidx.compose.runtime.Immutable + +@Immutable +sealed class BrowseEffect { + data object OnRequestFocus : BrowseEffect() + + data object OnBooksAdded : BrowseEffect() + + data object OnNavigateToLibrary : BrowseEffect() + + data class OnUpdatePinnedPaths( + val path: String + ) : BrowseEffect() + + data object OnNavigateToBrowseSettings : BrowseEffect() +} \ No newline at end of file diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/browse/BrowseEvent.kt b/app/src/main/java/ua/acclorite/book_story/presentation/browse/BrowseEvent.kt index 12bf44ed..7a4f3fe1 100644 --- a/app/src/main/java/ua/acclorite/book_story/presentation/browse/BrowseEvent.kt +++ b/app/src/main/java/ua/acclorite/book_story/presentation/browse/BrowseEvent.kt @@ -6,9 +6,7 @@ package ua.acclorite.book_story.presentation.browse -import android.content.Context import androidx.compose.runtime.Immutable -import androidx.compose.ui.focus.FocusRequester import ua.acclorite.book_story.presentation.browse.model.SelectableFile import ua.acclorite.book_story.presentation.library.model.SelectableNullableBook @@ -23,9 +21,7 @@ sealed class BrowseEvent { val show: Boolean ) : BrowseEvent() - data class OnRequestFocus( - val focusRequester: FocusRequester - ) : BrowseEvent() + data object OnRequestFocus : BrowseEvent() data class OnSearchQueryChange( val query: String @@ -36,15 +32,9 @@ sealed class BrowseEvent { data object OnClearSelectedFiles : BrowseEvent() data class OnSelectFiles( - val includedFileFormats: List, val files: List ) : BrowseEvent() - data class OnSelectFile( - val includedFileFormats: List, - val file: SelectableFile - ) : BrowseEvent() - data object OnShowFilterBottomSheet : BrowseEvent() data object OnDismissBottomSheet : BrowseEvent() @@ -57,10 +47,15 @@ sealed class BrowseEvent { val book: SelectableNullableBook ) : BrowseEvent() - data class OnActionAddDialog( - val context: Context, - val navigateToLibrary: () -> Unit - ) : BrowseEvent() + data object OnActionAddDialog : BrowseEvent() data object OnDismissDialog : BrowseEvent() + + data class OnUpdatePinnedPaths( + val path: String + ) : BrowseEvent() + + data object OnNavigateToLibrary : BrowseEvent() + + data object OnNavigateToBrowseSettings : BrowseEvent() } \ No newline at end of file diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/browse/BrowseModel.kt b/app/src/main/java/ua/acclorite/book_story/presentation/browse/BrowseModel.kt index b51c9353..95d6c6e3 100644 --- a/app/src/main/java/ua/acclorite/book_story/presentation/browse/BrowseModel.kt +++ b/app/src/main/java/ua/acclorite/book_story/presentation/browse/BrowseModel.kt @@ -13,7 +13,10 @@ import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.receiveAsFlow @@ -21,25 +24,21 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext -import kotlinx.coroutines.yield -import ua.acclorite.book_story.R -import ua.acclorite.book_story.core.helpers.compareByWithOrder import ua.acclorite.book_story.data.model.common.NullableBook import ua.acclorite.book_story.domain.model.file.File import ua.acclorite.book_story.domain.use_case.book.AddBookUseCase import ua.acclorite.book_story.domain.use_case.file_system.GetBookFromFileUseCase -import ua.acclorite.book_story.domain.use_case.file_system.SearchFilesUseCase -import ua.acclorite.book_story.presentation.browse.model.BrowseSortOrder +import ua.acclorite.book_story.domain.use_case.file_system.GetFilesUseCase import ua.acclorite.book_story.presentation.browse.model.SelectableFile import ua.acclorite.book_story.presentation.library.LibraryScreen import ua.acclorite.book_story.presentation.library.model.SelectableNullableBook -import ua.acclorite.book_story.ui.common.helpers.showToast import javax.inject.Inject +import kotlin.coroutines.coroutineContext @HiltViewModel class BrowseModel @Inject constructor( private val addBookUseCase: AddBookUseCase, - private val searchFilesUseCase: SearchFilesUseCase, + private val getFilesUseCase: GetFilesUseCase, private val getBookFromFileUseCase: GetBookFromFileUseCase ) : ViewModel() { @@ -48,18 +47,19 @@ class BrowseModel @Inject constructor( private val _state = MutableStateFlow(BrowseState()) val state = _state.asStateFlow() + private val _effects = MutableSharedFlow() + val effects = _effects.asSharedFlow() + init { - viewModelScope.launch(Dispatchers.IO) { - onEvent( - BrowseEvent.OnRefreshList( - loading = true, - hideSearch = true - ) + onEvent( + BrowseEvent.OnRefreshList( + loading = true, + hideSearch = true ) - } + ) /* Observe channel - - - - - - - - - - - */ - viewModelScope.launch(Dispatchers.IO) { + viewModelScope.launch { BrowseScreen.refreshListChannel.receiveAsFlow().collectLatest { onEvent( BrowseEvent.OnRefreshList( @@ -73,37 +73,57 @@ class BrowseModel @Inject constructor( } private var refreshJob: Job? = null - private var changeSearchQueryJob: Job? = null + private var searchQueryChangeJob: Job? = null private var getAddDialogBooksJob: Job? = null fun onEvent(event: BrowseEvent) { - when (event) { - is BrowseEvent.OnRefreshList -> { - refreshJob?.cancel() - refreshJob = viewModelScope.launch(Dispatchers.IO) { - _state.update { - it.copy( - isRefreshing = true, - isLoading = event.loading, - showSearch = if (event.hideSearch) false else it.showSearch - ) - } + viewModelScope.launch { + when (event) { + is BrowseEvent.OnRefreshList -> { + refreshJob?.cancel() + refreshJob = viewModelScope.launch(Dispatchers.Default) { + _state.update { + it.copy( + isRefreshing = true, + isLoading = event.loading, + showSearch = if (event.hideSearch) false else it.showSearch + ) + } - yield() - getFilesFromDownloads() + ensureActive() + val selectableFiles = getFilesUseCase( + if (_state.value.showSearch) _state.value.searchQuery + else "" + ).map { file -> + SelectableFile( + name = file.name, + uri = file.uri.toUri(), + path = file.path, + size = file.size, + lastModified = file.lastModified, + isDirectory = file.isDirectory, + selected = false + ) + } + _state.update { + it.copy( + files = selectableFiles, + hasSelectedItems = false, + isLoading = false + ) + } - delay(500) - _state.update { - it.copy( - isRefreshing = false, - isLoading = false - ) + + delay(500) // Delay for UI smoothness + _state.update { + it.copy( + isRefreshing = false + ) + } } } - } - is BrowseEvent.OnSearchVisibility -> { - viewModelScope.launch(Dispatchers.IO) { + is BrowseEvent.OnSearchVisibility -> { if (!event.show) { onEvent( BrowseEvent.OnRefreshList( @@ -111,27 +131,20 @@ class BrowseModel @Inject constructor( hideSearch = true ) ) - } else { - _state.update { - it.copy( - searchQuery = "", - hasFocused = false - ) - } } _state.update { it.copy( - showSearch = event.show + showSearch = event.show, + searchQuery = if (event.show) "" else it.searchQuery, + hasFocused = if (event.show) false else it.hasFocused ) } } - } - is BrowseEvent.OnRequestFocus -> { - viewModelScope.launch(Dispatchers.Main) { + is BrowseEvent.OnRequestFocus -> { if (!_state.value.hasFocused) { - event.focusRequester.requestFocus() + _effects.emit(BrowseEffect.OnRequestFocus) _state.update { it.copy( hasFocused = true @@ -139,26 +152,22 @@ class BrowseModel @Inject constructor( } } } - } - is BrowseEvent.OnSearchQueryChange -> { - viewModelScope.launch { + is BrowseEvent.OnSearchQueryChange -> { _state.update { it.copy( searchQuery = event.query ) } - changeSearchQueryJob?.cancel() - changeSearchQueryJob = launch(Dispatchers.IO) { + + searchQueryChangeJob?.cancel() + searchQueryChangeJob = viewModelScope.launch(Dispatchers.IO) { delay(500) - yield() onEvent(BrowseEvent.OnSearch) } } - } - is BrowseEvent.OnSearch -> { - viewModelScope.launch(Dispatchers.IO) { + is BrowseEvent.OnSearch -> { onEvent( BrowseEvent.OnRefreshList( loading = false, @@ -166,106 +175,62 @@ class BrowseModel @Inject constructor( ) ) } - } - is BrowseEvent.OnClearSelectedFiles -> { - viewModelScope.launch(Dispatchers.IO) { - _state.update { - it.copy( - files = it.files.map { file -> file.copy(selected = false) }, - hasSelectedItems = false - ) - } - } - } - - is BrowseEvent.OnSelectFiles -> { - viewModelScope.launch(Dispatchers.IO) { - val editedList = _state.value.files.map { file -> - if ( - event.files.any { - file.path.startsWith(it.path) - } && event.includedFileFormats.run { - if (isEmpty()) return@run true - any { - file.path.endsWith(it, ignoreCase = true) - } - } - ) { - file.copy(selected = true) - } else { - file - } - } - - _state.update { - it.copy( - files = editedList, - selectedItemsCount = editedList.filter { file -> - file.selected - }.size.run { - if (this == 0) return@run it.selectedItemsCount - this - }, - hasSelectedItems = editedList.any { file -> - file.selected - } - ) - } - } - } - - is BrowseEvent.OnSelectFile -> { - viewModelScope.launch(Dispatchers.IO) { - val editedList = _state.value.files.map { file -> - if (event.file.path == file.path) { - file.copy( - selected = !file.selected + is BrowseEvent.OnClearSelectedFiles -> { + withContext(Dispatchers.Default) { + _state.update { + it.copy( + files = it.files.map { file -> file.copy(selected = false) }, + hasSelectedItems = false ) - } else { - file } } + } - _state.update { - it.copy( - files = editedList, - selectedItemsCount = editedList.filter { file -> - file.selected - }.size.run { - if (this == 0) return@run it.selectedItemsCount - this - }, - hasSelectedItems = editedList.any { file -> - file.selected - } - ) + is BrowseEvent.OnSelectFiles -> { + withContext(Dispatchers.Default) { + val editedList = _state.value.files.map { file -> + if (event.files.any { file.path.startsWith(it.path, true) }) { + file.copy( + selected = if (event.files.size > 1) true else !file.selected + ) + } else file + } + + _state.update { + it.copy( + files = editedList, + selectedItemsCount = editedList.filter { file -> + file.selected + }.size.let { selectedItems -> + if (selectedItems == 0) return@let it.selectedItemsCount + selectedItems + }, + hasSelectedItems = editedList.any { file -> + file.selected + } + ) + } } } - } - is BrowseEvent.OnShowFilterBottomSheet -> { - viewModelScope.launch { + is BrowseEvent.OnShowFilterBottomSheet -> { _state.update { it.copy( bottomSheet = BrowseScreen.FILTER_BOTTOM_SHEET ) } } - } - is BrowseEvent.OnDismissBottomSheet -> { - viewModelScope.launch { + is BrowseEvent.OnDismissBottomSheet -> { _state.update { it.copy( bottomSheet = null ) } } - } - is BrowseEvent.OnShowAddDialog -> { - viewModelScope.launch { + is BrowseEvent.OnShowAddDialog -> { _state.update { it.copy( dialog = BrowseScreen.ADD_DIALOG, @@ -273,62 +238,51 @@ class BrowseModel @Inject constructor( ) } - getAddDialogBooksJob = launch(Dispatchers.IO) { + getAddDialogBooksJob = viewModelScope.launch(Dispatchers.IO) { _state.update { it.copy( loadingAddDialog = true ) } - yield() + val books = _state.value.files.mapNotNull { file -> + ensureActive() + if (!file.selected) return@mapNotNull null + SelectableNullableBook( + data = getBookFromFileUseCase( + File( + name = file.name, + uri = file.uri.toString(), + path = file.path, + size = file.size, + lastModified = file.lastModified, + isDirectory = file.isDirectory + ) + ), + selected = true + ) + } - val books = mutableListOf() - _state.value.files - .filter { it.selected } - .ifEmpty { - _state.update { - it.copy( - loadingAddDialog = false, - dialog = null - ) - } - return@launch - } - .forEach { - yield() - books.add( - getBookFromFileUseCase( - File( - name = it.name, - uri = it.uri.toString(), - path = it.path, - size = it.size, - lastModified = it.lastModified, - isDirectory = it.isDirectory - ) - ) + if (books.isEmpty()) { + _state.update { + it.copy( + loadingAddDialog = false, + dialog = null ) } - - yield() + return@launch + } _state.update { it.copy( - selectedBooksAddDialog = books.map { book -> - SelectableNullableBook( - data = book, - selected = true - ) - }, + selectedBooksAddDialog = books, loadingAddDialog = false ) } } } - } - is BrowseEvent.OnDismissAddDialog -> { - viewModelScope.launch { + is BrowseEvent.OnDismissAddDialog -> { _state.update { it.copy( dialog = null @@ -336,160 +290,94 @@ class BrowseModel @Inject constructor( } getAddDialogBooksJob?.cancel() } - } - is BrowseEvent.OnActionAddDialog -> { - viewModelScope.launch { - val booksToInsert = _state.value.selectedBooksAddDialog.mapNotNull { - if (it.data is NullableBook.NotNull && it.selected) { - return@mapNotNull it.data - } - return@mapNotNull null - }.ifEmpty { return@launch } - - for (book in booksToInsert) { - book.bookWithCover?.let { + is BrowseEvent.OnActionAddDialog -> { + withContext(Dispatchers.Default) { + _state.value.selectedBooksAddDialog.mapNotNull { + if (it.data is NullableBook.NotNull && it.selected) return@mapNotNull it.data.bookWithCover + return@mapNotNull null + }.ifEmpty { return@withContext }.forEach { bookWithCover -> addBookUseCase( - it.book, - it.coverImage + bookWithCover.book, + bookWithCover.coverImage + ) + } + + LibraryScreen.refreshListChannel.trySend(0) + LibraryScreen.scrollToPageCompositionChannel.trySend(0) + + _effects.emit(BrowseEffect.OnNavigateToLibrary) + _effects.emit(BrowseEffect.OnBooksAdded) + + _state.update { + it.copy( + dialog = null + ) + } + onEvent( + BrowseEvent.OnRefreshList( + loading = false, + hideSearch = false + ) + ) + onEvent(BrowseEvent.OnClearSelectedFiles) + } + } + + is BrowseEvent.OnSelectAddDialog -> { + withContext(Dispatchers.Default) { + val index = _state.value.selectedBooksAddDialog.indexOf(event.book) + if (index == -1) return@withContext + + val editedList = _state.value.selectedBooksAddDialog.toMutableList() + editedList[index] = editedList[index].let { + it.copy( + selected = !it.selected + ) + } + + if ( + !editedList + .filter { it.data is NullableBook.NotNull } + .any { it.selected } + ) { + return@withContext + } + + _state.update { + it.copy( + selectedBooksAddDialog = editedList ) } } - - LibraryScreen.refreshListChannel.trySend(0) - LibraryScreen.scrollToPageCompositionChannel.trySend(0) - - event.navigateToLibrary() - withContext(Dispatchers.Main) { - event.context - .getString(R.string.books_added) - .showToast(context = event.context) - } - - _state.update { - it.copy( - dialog = null - ) - } - onEvent( - BrowseEvent.OnRefreshList( - loading = false, - hideSearch = false - ) - ) - onEvent(BrowseEvent.OnClearSelectedFiles) } - } - is BrowseEvent.OnSelectAddDialog -> { - viewModelScope.launch(Dispatchers.IO) { - val index = _state.value.selectedBooksAddDialog.indexOf(event.book).apply { - if (this == -1) { - return@launch - } - } - - val editedList = _state.value.selectedBooksAddDialog.toMutableList() - editedList[index] = editedList[index].run { copy(selected = !selected) } - - if (!editedList.filter { it.data is NullableBook.NotNull } - .any { it.selected }) { - return@launch - } - - _state.update { - it.copy( - selectedBooksAddDialog = editedList - ) - } - } - } - - is BrowseEvent.OnDismissDialog -> { - viewModelScope.launch { + is BrowseEvent.OnDismissDialog -> { _state.update { it.copy( dialog = null ) } } - } - } - } - private suspend fun getFilesFromDownloads( - query: String = if (_state.value.showSearch) _state.value.searchQuery else "" - ) { - searchFilesUseCase(query).apply { - yield() - _state.update { - it.copy( - files = map { - SelectableFile( - name = it.name, - uri = it.uri.toUri(), - path = it.path, - size = it.size, - lastModified = it.lastModified, - isDirectory = it.isDirectory, - selected = false - ) - }, - hasSelectedItems = false, - isLoading = false - ) - } - } - } + is BrowseEvent.OnUpdatePinnedPaths -> { + _effects.emit(BrowseEffect.OnUpdatePinnedPaths(event.path)) + } - fun filterList( - files: List, - sortOrderDescending: Boolean, - includedFilterItems: List, - sortOrder: BrowseSortOrder - ): List { - fun List.filterFiles(): List { - if (includedFilterItems.isEmpty()) { - return this - } + is BrowseEvent.OnNavigateToLibrary -> { + _effects.emit(BrowseEffect.OnNavigateToLibrary) + } - return filter { file -> - includedFilterItems.any { - file.path.endsWith( - it, ignoreCase = true - ) + is BrowseEvent.OnNavigateToBrowseSettings -> { + _effects.emit(BrowseEffect.OnNavigateToBrowseSettings) } } } - - return files - .filterFiles() - .sortedWith( - compareByWithOrder(sortOrderDescending) { - when (sortOrder) { - BrowseSortOrder.NAME -> { - it.name.trim() - } - - BrowseSortOrder.FILE_FORMAT -> { - it.path.substringAfterLast(".").lowercase().trimEnd() - } - - BrowseSortOrder.FILE_SIZE -> { - it.size - } - - else -> { - it.lastModified - } - } - } - ) } private suspend inline fun MutableStateFlow.update(function: (T) -> T) { mutex.withLock { - yield() + coroutineContext.ensureActive() this.value = function(this.value) } } diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/browse/BrowseScreen.kt b/app/src/main/java/ua/acclorite/book_story/presentation/browse/BrowseScreen.kt index e5fb5062..1709fe53 100644 --- a/app/src/main/java/ua/acclorite/book_story/presentation/browse/BrowseScreen.kt +++ b/app/src/main/java/ua/acclorite/book_story/presentation/browse/BrowseScreen.kt @@ -23,14 +23,11 @@ import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.Parcelize -import ua.acclorite.book_story.core.helpers.toggle import ua.acclorite.book_story.presentation.browse.model.BrowseLayout -import ua.acclorite.book_story.presentation.library.LibraryScreen import ua.acclorite.book_story.presentation.navigator.Screen -import ua.acclorite.book_story.presentation.settings.BrowseSettingsScreen import ua.acclorite.book_story.ui.browse.BrowseContent +import ua.acclorite.book_story.ui.browse.BrowseEffects import ua.acclorite.book_story.ui.common.helpers.LocalSettings -import ua.acclorite.book_story.ui.navigator.LocalNavigator @Parcelize object BrowseScreen : Screen, Parcelable { @@ -62,7 +59,6 @@ object BrowseScreen : Screen, Parcelable { @OptIn(ExperimentalMaterialApi::class) @Composable override fun Content() { - val navigator = LocalNavigator.current val screenModel = hiltViewModel() val settings = LocalSettings.current @@ -84,17 +80,16 @@ object BrowseScreen : Screen, Parcelable { } ) - val files = remember( - state.value.files, + LaunchedEffect( settings.browseIncludedFilterItems.value, settings.browseSortOrderDescending.value, settings.browseSortOrder.value ) { - screenModel.filterList( - files = state.value.files, - sortOrderDescending = settings.browseSortOrderDescending.lastValue, - includedFilterItems = settings.browseIncludedFilterItems.lastValue, - sortOrder = settings.browseSortOrder.lastValue + screenModel.onEvent( + BrowseEvent.OnRefreshList( + loading = false, + hideSearch = false + ) ) } @@ -128,8 +123,13 @@ object BrowseScreen : Screen, Parcelable { } } + BrowseEffects( + effects = screenModel.effects, + focusRequester = focusRequester + ) + BrowseContent( - files = files, + files = state.value.files, selectedBooksAddDialog = state.value.selectedBooksAddDialog, refreshState = refreshState, loadingAddDialog = state.value.loadingAddDialog, @@ -149,7 +149,7 @@ object BrowseScreen : Screen, Parcelable { isRefreshing = state.value.isRefreshing, isLoading = state.value.isLoading, dialogHidden = state.value.dialog == null, - filesEmpty = files.isEmpty(), + filesEmpty = state.value.files.isEmpty(), showSearch = state.value.showSearch, searchQuery = state.value.searchQuery, focusRequester = focusRequester, @@ -159,24 +159,15 @@ object BrowseScreen : Screen, Parcelable { requestFocus = screenModel::onEvent, clearSelectedFiles = screenModel::onEvent, selectFiles = screenModel::onEvent, - selectFile = screenModel::onEvent, showFilterBottomSheet = screenModel::onEvent, dismissBottomSheet = screenModel::onEvent, showAddDialog = screenModel::onEvent, dismissAddDialog = screenModel::onEvent, selectAddDialog = screenModel::onEvent, actionAddDialog = screenModel::onEvent, - updatePinnedPaths = { - settings.browsePinnedPaths.update( - settings.browsePinnedPaths.lastValue.toggle(it) - ) - }, - navigateToLibrary = { - navigator.push(LibraryScreen, saveInBackStack = false) - }, - navigateToBrowseSettings = { - navigator.push(BrowseSettingsScreen) - }, + updatePinnedPaths = screenModel::onEvent, + navigateToLibrary = screenModel::onEvent, + navigateToBrowseSettings = screenModel::onEvent ) } } \ No newline at end of file diff --git a/app/src/main/java/ua/acclorite/book_story/ui/browse/BrowseAddDialog.kt b/app/src/main/java/ua/acclorite/book_story/ui/browse/BrowseAddDialog.kt index 903a07c1..f7b1059a 100644 --- a/app/src/main/java/ua/acclorite/book_story/ui/browse/BrowseAddDialog.kt +++ b/app/src/main/java/ua/acclorite/book_story/ui/browse/BrowseAddDialog.kt @@ -34,8 +34,7 @@ fun BrowseAddDialog( selectedBooksAddDialog: List, dismissAddDialog: (BrowseEvent.OnDismissAddDialog) -> Unit, actionAddDialog: (BrowseEvent.OnActionAddDialog) -> Unit, - selectAddDialog: (BrowseEvent.OnSelectAddDialog) -> Unit, - navigateToLibrary: () -> Unit + selectAddDialog: (BrowseEvent.OnSelectAddDialog) -> Unit ) { val context = LocalContext.current Dialog( @@ -45,12 +44,7 @@ fun BrowseAddDialog( actionEnabled = !loadingAddDialog && selectedBooksAddDialog.any { it.data is NullableBook.NotNull }, onDismiss = { dismissAddDialog(BrowseEvent.OnDismissAddDialog) }, onAction = { - actionAddDialog( - BrowseEvent.OnActionAddDialog( - context = context, - navigateToLibrary = navigateToLibrary - ) - ) + actionAddDialog(BrowseEvent.OnActionAddDialog) }, withContent = true, items = { diff --git a/app/src/main/java/ua/acclorite/book_story/ui/browse/BrowseBackHandler.kt b/app/src/main/java/ua/acclorite/book_story/ui/browse/BrowseBackHandler.kt index c3553eb3..6ce10581 100644 --- a/app/src/main/java/ua/acclorite/book_story/ui/browse/BrowseBackHandler.kt +++ b/app/src/main/java/ua/acclorite/book_story/ui/browse/BrowseBackHandler.kt @@ -16,7 +16,7 @@ fun BrowseBackHandler( showSearch: Boolean, searchVisibility: (BrowseEvent.OnSearchVisibility) -> Unit, clearSelectedFiles: (BrowseEvent.OnClearSelectedFiles) -> Unit, - navigateToLibrary: () -> Unit + navigateToLibrary: (BrowseEvent.OnNavigateToLibrary) -> Unit ) { BackHandler { if (hasSelectedItems) { @@ -29,6 +29,6 @@ fun BrowseBackHandler( return@BackHandler } - navigateToLibrary() + navigateToLibrary(BrowseEvent.OnNavigateToLibrary) } } \ No newline at end of file diff --git a/app/src/main/java/ua/acclorite/book_story/ui/browse/BrowseContent.kt b/app/src/main/java/ua/acclorite/book_story/ui/browse/BrowseContent.kt index b4a0097e..648fe2b3 100644 --- a/app/src/main/java/ua/acclorite/book_story/ui/browse/BrowseContent.kt +++ b/app/src/main/java/ua/acclorite/book_story/ui/browse/BrowseContent.kt @@ -52,16 +52,15 @@ fun BrowseContent( requestFocus: (BrowseEvent.OnRequestFocus) -> Unit, clearSelectedFiles: (BrowseEvent.OnClearSelectedFiles) -> Unit, selectFiles: (BrowseEvent.OnSelectFiles) -> Unit, - selectFile: (BrowseEvent.OnSelectFile) -> Unit, dismissBottomSheet: (BrowseEvent.OnDismissBottomSheet) -> Unit, showFilterBottomSheet: (BrowseEvent.OnShowFilterBottomSheet) -> Unit, showAddDialog: (BrowseEvent.OnShowAddDialog) -> Unit, dismissAddDialog: (BrowseEvent.OnDismissAddDialog) -> Unit, actionAddDialog: (BrowseEvent.OnActionAddDialog) -> Unit, selectAddDialog: (BrowseEvent.OnSelectAddDialog) -> Unit, - updatePinnedPaths: (String) -> Unit, - navigateToLibrary: () -> Unit, - navigateToBrowseSettings: () -> Unit, + updatePinnedPaths: (BrowseEvent.OnUpdatePinnedPaths) -> Unit, + navigateToLibrary: (BrowseEvent.OnNavigateToLibrary) -> Unit, + navigateToBrowseSettings: (BrowseEvent.OnNavigateToBrowseSettings) -> Unit ) { BrowseDialog( dialog = dialog, @@ -69,8 +68,7 @@ fun BrowseContent( actionAddDialog = actionAddDialog, dismissAddDialog = dismissAddDialog, selectedBooksAddDialog = selectedBooksAddDialog, - selectAddDialog = selectAddDialog, - navigateToLibrary = navigateToLibrary + selectAddDialog = selectAddDialog ) BrowseBottomSheet( @@ -105,7 +103,6 @@ fun BrowseContent( selectFiles = selectFiles, isLoading = isLoading, filesEmpty = filesEmpty, - selectFile = selectFile, showFilterBottomSheet = showFilterBottomSheet, showAddDialog = showAddDialog, updatePinnedPaths = updatePinnedPaths, diff --git a/app/src/main/java/ua/acclorite/book_story/ui/browse/BrowseDialog.kt b/app/src/main/java/ua/acclorite/book_story/ui/browse/BrowseDialog.kt index 273e35e5..77becb25 100644 --- a/app/src/main/java/ua/acclorite/book_story/ui/browse/BrowseDialog.kt +++ b/app/src/main/java/ua/acclorite/book_story/ui/browse/BrowseDialog.kt @@ -19,8 +19,7 @@ fun BrowseDialog( selectedBooksAddDialog: List, dismissAddDialog: (BrowseEvent.OnDismissAddDialog) -> Unit, actionAddDialog: (BrowseEvent.OnActionAddDialog) -> Unit, - selectAddDialog: (BrowseEvent.OnSelectAddDialog) -> Unit, - navigateToLibrary: () -> Unit + selectAddDialog: (BrowseEvent.OnSelectAddDialog) -> Unit ) { when (dialog) { BrowseScreen.ADD_DIALOG -> { @@ -29,8 +28,7 @@ fun BrowseDialog( selectedBooksAddDialog = selectedBooksAddDialog, dismissAddDialog = dismissAddDialog, actionAddDialog = actionAddDialog, - selectAddDialog = selectAddDialog, - navigateToLibrary = navigateToLibrary + selectAddDialog = selectAddDialog ) } } diff --git a/app/src/main/java/ua/acclorite/book_story/ui/browse/BrowseEffects.kt b/app/src/main/java/ua/acclorite/book_story/ui/browse/BrowseEffects.kt new file mode 100644 index 00000000..a267d118 --- /dev/null +++ b/app/src/main/java/ua/acclorite/book_story/ui/browse/BrowseEffects.kt @@ -0,0 +1,57 @@ +/* + * Book's Story — free and open-source Material You eBook reader. + * Copyright (C) 2024-2025 Acclorite + * SPDX-License-Identifier: GPL-3.0-only + */ + +package ua.acclorite.book_story.ui.browse + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.platform.LocalContext +import kotlinx.coroutines.flow.SharedFlow +import ua.acclorite.book_story.R +import ua.acclorite.book_story.core.helpers.toggle +import ua.acclorite.book_story.presentation.browse.BrowseEffect +import ua.acclorite.book_story.presentation.library.LibraryScreen +import ua.acclorite.book_story.presentation.settings.BrowseSettingsScreen +import ua.acclorite.book_story.ui.common.helpers.LocalSettings +import ua.acclorite.book_story.ui.common.helpers.showToast +import ua.acclorite.book_story.ui.navigator.LocalNavigator + +@Composable +fun BrowseEffects(effects: SharedFlow, focusRequester: FocusRequester) { + val navigator = LocalNavigator.current + val settings = LocalSettings.current + val context = LocalContext.current + + LaunchedEffect(effects, focusRequester, context, navigator, settings.browsePinnedPaths.value) { + effects.collect { effect -> + when (effect) { + is BrowseEffect.OnRequestFocus -> { + focusRequester.requestFocus() + } + + is BrowseEffect.OnBooksAdded -> { + context.getString(R.string.books_added) + .showToast(context = context) + } + + is BrowseEffect.OnNavigateToLibrary -> { + navigator.push(LibraryScreen, saveInBackStack = false) + } + + is BrowseEffect.OnUpdatePinnedPaths -> { + settings.browsePinnedPaths.update( + settings.browsePinnedPaths.lastValue.toggle(effect.path) + ) + } + + is BrowseEffect.OnNavigateToBrowseSettings -> { + navigator.push(BrowseSettingsScreen) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ua/acclorite/book_story/ui/browse/BrowseEmptyPlaceholder.kt b/app/src/main/java/ua/acclorite/book_story/ui/browse/BrowseEmptyPlaceholder.kt index fd688bb0..970686db 100644 --- a/app/src/main/java/ua/acclorite/book_story/ui/browse/BrowseEmptyPlaceholder.kt +++ b/app/src/main/java/ua/acclorite/book_story/ui/browse/BrowseEmptyPlaceholder.kt @@ -13,6 +13,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import ua.acclorite.book_story.R +import ua.acclorite.book_story.presentation.browse.BrowseEvent import ua.acclorite.book_story.ui.common.components.common.AnimatedVisibility import ua.acclorite.book_story.ui.common.components.placeholder.EmptyPlaceholder import ua.acclorite.book_story.ui.theme.Transitions @@ -23,7 +24,7 @@ fun BoxScope.BrowseEmptyPlaceholder( dialogHidden: Boolean, isLoading: Boolean, isRefreshing: Boolean, - navigateToBrowseSettings: () -> Unit + navigateToBrowseSettings: (BrowseEvent.OnNavigateToBrowseSettings) -> Unit ) { AnimatedVisibility( visible = !isLoading @@ -39,7 +40,7 @@ fun BoxScope.BrowseEmptyPlaceholder( icon = painterResource(id = R.drawable.empty_browse), actionTitle = stringResource(id = R.string.set_up_scanning), action = { - navigateToBrowseSettings() + navigateToBrowseSettings(BrowseEvent.OnNavigateToBrowseSettings) } ) } diff --git a/app/src/main/java/ua/acclorite/book_story/ui/browse/BrowseScaffold.kt b/app/src/main/java/ua/acclorite/book_story/ui/browse/BrowseScaffold.kt index cd49a9fc..ef90a639 100644 --- a/app/src/main/java/ua/acclorite/book_story/ui/browse/BrowseScaffold.kt +++ b/app/src/main/java/ua/acclorite/book_story/ui/browse/BrowseScaffold.kt @@ -53,11 +53,10 @@ fun BrowseScaffold( requestFocus: (BrowseEvent.OnRequestFocus) -> Unit, clearSelectedFiles: (BrowseEvent.OnClearSelectedFiles) -> Unit, selectFiles: (BrowseEvent.OnSelectFiles) -> Unit, - selectFile: (BrowseEvent.OnSelectFile) -> Unit, showFilterBottomSheet: (BrowseEvent.OnShowFilterBottomSheet) -> Unit, showAddDialog: (BrowseEvent.OnShowAddDialog) -> Unit, - updatePinnedPaths: (String) -> Unit, - navigateToBrowseSettings: () -> Unit, + updatePinnedPaths: (BrowseEvent.OnUpdatePinnedPaths) -> Unit, + navigateToBrowseSettings: (BrowseEvent.OnNavigateToBrowseSettings) -> Unit ) { Scaffold( modifier = Modifier @@ -106,7 +105,11 @@ fun BrowseScaffold( header = header, pinned = pinned, pin = { - updatePinnedPaths(header) + updatePinnedPaths( + BrowseEvent.OnUpdatePinnedPaths( + path = header + ) + ) } ) }, @@ -118,16 +121,14 @@ fun BrowseScaffold( onLongClick = { selectFiles( BrowseEvent.OnSelectFiles( - includedFileFormats = includedFilterItems, files = groupFiles ) ) }, onClick = { - selectFile( - BrowseEvent.OnSelectFile( - includedFileFormats = includedFilterItems, - file = file + selectFiles( + BrowseEvent.OnSelectFiles( + files = listOf(file) ) ) } diff --git a/app/src/main/java/ua/acclorite/book_story/ui/browse/BrowseTopBar.kt b/app/src/main/java/ua/acclorite/book_story/ui/browse/BrowseTopBar.kt index 32a2b702..070de93f 100644 --- a/app/src/main/java/ua/acclorite/book_story/ui/browse/BrowseTopBar.kt +++ b/app/src/main/java/ua/acclorite/book_story/ui/browse/BrowseTopBar.kt @@ -128,7 +128,7 @@ fun BrowseTopBar( modifier = Modifier .focusRequester(focusRequester) .onGloballyPositioned { - requestFocus(BrowseEvent.OnRequestFocus(focusRequester)) + requestFocus(BrowseEvent.OnRequestFocus) }, initialQuery = searchQuery, onQueryChange = { @@ -172,7 +172,6 @@ fun BrowseTopBar( ) { selectFiles( BrowseEvent.OnSelectFiles( - includedFileFormats = includedFilterItems, files = files ) )