refactor: browse viewmodel

This commit is contained in:
Acclorite 2025-08-14 23:13:10 +03:00
parent 6f53c70b5b
commit d50b1a5f9a
No known key found for this signature in database
GPG key ID: 6E54C611F6EE8593
14 changed files with 398 additions and 414 deletions

View file

@ -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<File> {
logI("Searching for files with query: \"$query\".")
fun List<File>.filterFiles(): List<File> {
return if (settings.browseIncludedFilterItems.lastValue.isEmpty()) this
else filter { file ->
settings.browseIncludedFilterItems.lastValue.any { extension ->
file.path.endsWith(
extension, ignoreCase = true
)
}
}
}
fun List<File>.sortFiles(): List<File> {
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()
}
)
}
}

View file

@ -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<File> {
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()
}
)
}
}

View file

@ -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()
}

View file

@ -6,9 +6,7 @@
package ua.acclorite.book_story.presentation.browse package ua.acclorite.book_story.presentation.browse
import android.content.Context
import androidx.compose.runtime.Immutable 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.browse.model.SelectableFile
import ua.acclorite.book_story.presentation.library.model.SelectableNullableBook import ua.acclorite.book_story.presentation.library.model.SelectableNullableBook
@ -23,9 +21,7 @@ sealed class BrowseEvent {
val show: Boolean val show: Boolean
) : BrowseEvent() ) : BrowseEvent()
data class OnRequestFocus( data object OnRequestFocus : BrowseEvent()
val focusRequester: FocusRequester
) : BrowseEvent()
data class OnSearchQueryChange( data class OnSearchQueryChange(
val query: String val query: String
@ -36,15 +32,9 @@ sealed class BrowseEvent {
data object OnClearSelectedFiles : BrowseEvent() data object OnClearSelectedFiles : BrowseEvent()
data class OnSelectFiles( data class OnSelectFiles(
val includedFileFormats: List<String>,
val files: List<SelectableFile> val files: List<SelectableFile>
) : BrowseEvent() ) : BrowseEvent()
data class OnSelectFile(
val includedFileFormats: List<String>,
val file: SelectableFile
) : BrowseEvent()
data object OnShowFilterBottomSheet : BrowseEvent() data object OnShowFilterBottomSheet : BrowseEvent()
data object OnDismissBottomSheet : BrowseEvent() data object OnDismissBottomSheet : BrowseEvent()
@ -57,10 +47,15 @@ sealed class BrowseEvent {
val book: SelectableNullableBook val book: SelectableNullableBook
) : BrowseEvent() ) : BrowseEvent()
data class OnActionAddDialog( data object OnActionAddDialog : BrowseEvent()
val context: Context,
val navigateToLibrary: () -> Unit
) : BrowseEvent()
data object OnDismissDialog : BrowseEvent() data object OnDismissDialog : BrowseEvent()
data class OnUpdatePinnedPaths(
val path: String
) : BrowseEvent()
data object OnNavigateToLibrary : BrowseEvent()
data object OnNavigateToBrowseSettings : BrowseEvent()
} }

View file

@ -13,7 +13,10 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.receiveAsFlow
@ -21,25 +24,21 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext 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.data.model.common.NullableBook
import ua.acclorite.book_story.domain.model.file.File 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.book.AddBookUseCase
import ua.acclorite.book_story.domain.use_case.file_system.GetBookFromFileUseCase 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.domain.use_case.file_system.GetFilesUseCase
import ua.acclorite.book_story.presentation.browse.model.BrowseSortOrder
import ua.acclorite.book_story.presentation.browse.model.SelectableFile import ua.acclorite.book_story.presentation.browse.model.SelectableFile
import ua.acclorite.book_story.presentation.library.LibraryScreen import ua.acclorite.book_story.presentation.library.LibraryScreen
import ua.acclorite.book_story.presentation.library.model.SelectableNullableBook import ua.acclorite.book_story.presentation.library.model.SelectableNullableBook
import ua.acclorite.book_story.ui.common.helpers.showToast
import javax.inject.Inject import javax.inject.Inject
import kotlin.coroutines.coroutineContext
@HiltViewModel @HiltViewModel
class BrowseModel @Inject constructor( class BrowseModel @Inject constructor(
private val addBookUseCase: AddBookUseCase, private val addBookUseCase: AddBookUseCase,
private val searchFilesUseCase: SearchFilesUseCase, private val getFilesUseCase: GetFilesUseCase,
private val getBookFromFileUseCase: GetBookFromFileUseCase private val getBookFromFileUseCase: GetBookFromFileUseCase
) : ViewModel() { ) : ViewModel() {
@ -48,18 +47,19 @@ class BrowseModel @Inject constructor(
private val _state = MutableStateFlow(BrowseState()) private val _state = MutableStateFlow(BrowseState())
val state = _state.asStateFlow() val state = _state.asStateFlow()
private val _effects = MutableSharedFlow<BrowseEffect>()
val effects = _effects.asSharedFlow()
init { init {
viewModelScope.launch(Dispatchers.IO) { onEvent(
onEvent( BrowseEvent.OnRefreshList(
BrowseEvent.OnRefreshList( loading = true,
loading = true, hideSearch = true
hideSearch = true
)
) )
} )
/* Observe channel - - - - - - - - - - - */ /* Observe channel - - - - - - - - - - - */
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch {
BrowseScreen.refreshListChannel.receiveAsFlow().collectLatest { BrowseScreen.refreshListChannel.receiveAsFlow().collectLatest {
onEvent( onEvent(
BrowseEvent.OnRefreshList( BrowseEvent.OnRefreshList(
@ -73,37 +73,57 @@ class BrowseModel @Inject constructor(
} }
private var refreshJob: Job? = null private var refreshJob: Job? = null
private var changeSearchQueryJob: Job? = null private var searchQueryChangeJob: Job? = null
private var getAddDialogBooksJob: Job? = null private var getAddDialogBooksJob: Job? = null
fun onEvent(event: BrowseEvent) { fun onEvent(event: BrowseEvent) {
when (event) { viewModelScope.launch {
is BrowseEvent.OnRefreshList -> { when (event) {
refreshJob?.cancel() is BrowseEvent.OnRefreshList -> {
refreshJob = viewModelScope.launch(Dispatchers.IO) { refreshJob?.cancel()
_state.update { refreshJob = viewModelScope.launch(Dispatchers.Default) {
it.copy( _state.update {
isRefreshing = true, it.copy(
isLoading = event.loading, isRefreshing = true,
showSearch = if (event.hideSearch) false else it.showSearch isLoading = event.loading,
) showSearch = if (event.hideSearch) false else it.showSearch
} )
}
yield() ensureActive()
getFilesFromDownloads() 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 { delay(500) // Delay for UI smoothness
it.copy( _state.update {
isRefreshing = false, it.copy(
isLoading = false isRefreshing = false
) )
}
} }
} }
}
is BrowseEvent.OnSearchVisibility -> { is BrowseEvent.OnSearchVisibility -> {
viewModelScope.launch(Dispatchers.IO) {
if (!event.show) { if (!event.show) {
onEvent( onEvent(
BrowseEvent.OnRefreshList( BrowseEvent.OnRefreshList(
@ -111,27 +131,20 @@ class BrowseModel @Inject constructor(
hideSearch = true hideSearch = true
) )
) )
} else {
_state.update {
it.copy(
searchQuery = "",
hasFocused = false
)
}
} }
_state.update { _state.update {
it.copy( 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 -> { is BrowseEvent.OnRequestFocus -> {
viewModelScope.launch(Dispatchers.Main) {
if (!_state.value.hasFocused) { if (!_state.value.hasFocused) {
event.focusRequester.requestFocus() _effects.emit(BrowseEffect.OnRequestFocus)
_state.update { _state.update {
it.copy( it.copy(
hasFocused = true hasFocused = true
@ -139,26 +152,22 @@ class BrowseModel @Inject constructor(
} }
} }
} }
}
is BrowseEvent.OnSearchQueryChange -> { is BrowseEvent.OnSearchQueryChange -> {
viewModelScope.launch {
_state.update { _state.update {
it.copy( it.copy(
searchQuery = event.query searchQuery = event.query
) )
} }
changeSearchQueryJob?.cancel()
changeSearchQueryJob = launch(Dispatchers.IO) { searchQueryChangeJob?.cancel()
searchQueryChangeJob = viewModelScope.launch(Dispatchers.IO) {
delay(500) delay(500)
yield()
onEvent(BrowseEvent.OnSearch) onEvent(BrowseEvent.OnSearch)
} }
} }
}
is BrowseEvent.OnSearch -> { is BrowseEvent.OnSearch -> {
viewModelScope.launch(Dispatchers.IO) {
onEvent( onEvent(
BrowseEvent.OnRefreshList( BrowseEvent.OnRefreshList(
loading = false, loading = false,
@ -166,106 +175,62 @@ class BrowseModel @Inject constructor(
) )
) )
} }
}
is BrowseEvent.OnClearSelectedFiles -> { is BrowseEvent.OnClearSelectedFiles -> {
viewModelScope.launch(Dispatchers.IO) { withContext(Dispatchers.Default) {
_state.update { _state.update {
it.copy( it.copy(
files = it.files.map { file -> file.copy(selected = false) }, files = it.files.map { file -> file.copy(selected = false) },
hasSelectedItems = 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
) )
} else {
file
} }
} }
}
_state.update { is BrowseEvent.OnSelectFiles -> {
it.copy( withContext(Dispatchers.Default) {
files = editedList, val editedList = _state.value.files.map { file ->
selectedItemsCount = editedList.filter { file -> if (event.files.any { file.path.startsWith(it.path, true) }) {
file.selected file.copy(
}.size.run { selected = if (event.files.size > 1) true else !file.selected
if (this == 0) return@run it.selectedItemsCount )
this } else file
}, }
hasSelectedItems = editedList.any { file ->
file.selected _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 -> { is BrowseEvent.OnShowFilterBottomSheet -> {
viewModelScope.launch {
_state.update { _state.update {
it.copy( it.copy(
bottomSheet = BrowseScreen.FILTER_BOTTOM_SHEET bottomSheet = BrowseScreen.FILTER_BOTTOM_SHEET
) )
} }
} }
}
is BrowseEvent.OnDismissBottomSheet -> { is BrowseEvent.OnDismissBottomSheet -> {
viewModelScope.launch {
_state.update { _state.update {
it.copy( it.copy(
bottomSheet = null bottomSheet = null
) )
} }
} }
}
is BrowseEvent.OnShowAddDialog -> { is BrowseEvent.OnShowAddDialog -> {
viewModelScope.launch {
_state.update { _state.update {
it.copy( it.copy(
dialog = BrowseScreen.ADD_DIALOG, dialog = BrowseScreen.ADD_DIALOG,
@ -273,62 +238,51 @@ class BrowseModel @Inject constructor(
) )
} }
getAddDialogBooksJob = launch(Dispatchers.IO) { getAddDialogBooksJob = viewModelScope.launch(Dispatchers.IO) {
_state.update { _state.update {
it.copy( it.copy(
loadingAddDialog = true 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<NullableBook>() if (books.isEmpty()) {
_state.value.files _state.update {
.filter { it.selected } it.copy(
.ifEmpty { loadingAddDialog = false,
_state.update { dialog = null
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
)
)
) )
} }
return@launch
yield() }
_state.update { _state.update {
it.copy( it.copy(
selectedBooksAddDialog = books.map { book -> selectedBooksAddDialog = books,
SelectableNullableBook(
data = book,
selected = true
)
},
loadingAddDialog = false loadingAddDialog = false
) )
} }
} }
} }
}
is BrowseEvent.OnDismissAddDialog -> { is BrowseEvent.OnDismissAddDialog -> {
viewModelScope.launch {
_state.update { _state.update {
it.copy( it.copy(
dialog = null dialog = null
@ -336,160 +290,94 @@ class BrowseModel @Inject constructor(
} }
getAddDialogBooksJob?.cancel() getAddDialogBooksJob?.cancel()
} }
}
is BrowseEvent.OnActionAddDialog -> { is BrowseEvent.OnActionAddDialog -> {
viewModelScope.launch { withContext(Dispatchers.Default) {
val booksToInsert = _state.value.selectedBooksAddDialog.mapNotNull { _state.value.selectedBooksAddDialog.mapNotNull {
if (it.data is NullableBook.NotNull && it.selected) { if (it.data is NullableBook.NotNull && it.selected) return@mapNotNull it.data.bookWithCover
return@mapNotNull it.data return@mapNotNull null
} }.ifEmpty { return@withContext }.forEach { bookWithCover ->
return@mapNotNull null
}.ifEmpty { return@launch }
for (book in booksToInsert) {
book.bookWithCover?.let {
addBookUseCase( addBookUseCase(
it.book, bookWithCover.book,
it.coverImage 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 -> { is BrowseEvent.OnDismissDialog -> {
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 {
_state.update { _state.update {
it.copy( it.copy(
dialog = null dialog = null
) )
} }
} }
}
}
}
private suspend fun getFilesFromDownloads( is BrowseEvent.OnUpdatePinnedPaths -> {
query: String = if (_state.value.showSearch) _state.value.searchQuery else "" _effects.emit(BrowseEffect.OnUpdatePinnedPaths(event.path))
) { }
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
)
}
}
}
fun filterList( is BrowseEvent.OnNavigateToLibrary -> {
files: List<SelectableFile>, _effects.emit(BrowseEffect.OnNavigateToLibrary)
sortOrderDescending: Boolean, }
includedFilterItems: List<String>,
sortOrder: BrowseSortOrder
): List<SelectableFile> {
fun List<SelectableFile>.filterFiles(): List<SelectableFile> {
if (includedFilterItems.isEmpty()) {
return this
}
return filter { file -> is BrowseEvent.OnNavigateToBrowseSettings -> {
includedFilterItems.any { _effects.emit(BrowseEffect.OnNavigateToBrowseSettings)
file.path.endsWith(
it, ignoreCase = true
)
} }
} }
} }
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 <T> MutableStateFlow<T>.update(function: (T) -> T) { private suspend inline fun <T> MutableStateFlow<T>.update(function: (T) -> T) {
mutex.withLock { mutex.withLock {
yield() coroutineContext.ensureActive()
this.value = function(this.value) this.value = function(this.value)
} }
} }

View file

@ -23,14 +23,11 @@ import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize 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.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.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.BrowseContent
import ua.acclorite.book_story.ui.browse.BrowseEffects
import ua.acclorite.book_story.ui.common.helpers.LocalSettings import ua.acclorite.book_story.ui.common.helpers.LocalSettings
import ua.acclorite.book_story.ui.navigator.LocalNavigator
@Parcelize @Parcelize
object BrowseScreen : Screen, Parcelable { object BrowseScreen : Screen, Parcelable {
@ -62,7 +59,6 @@ object BrowseScreen : Screen, Parcelable {
@OptIn(ExperimentalMaterialApi::class) @OptIn(ExperimentalMaterialApi::class)
@Composable @Composable
override fun Content() { override fun Content() {
val navigator = LocalNavigator.current
val screenModel = hiltViewModel<BrowseModel>() val screenModel = hiltViewModel<BrowseModel>()
val settings = LocalSettings.current val settings = LocalSettings.current
@ -84,17 +80,16 @@ object BrowseScreen : Screen, Parcelable {
} }
) )
val files = remember( LaunchedEffect(
state.value.files,
settings.browseIncludedFilterItems.value, settings.browseIncludedFilterItems.value,
settings.browseSortOrderDescending.value, settings.browseSortOrderDescending.value,
settings.browseSortOrder.value settings.browseSortOrder.value
) { ) {
screenModel.filterList( screenModel.onEvent(
files = state.value.files, BrowseEvent.OnRefreshList(
sortOrderDescending = settings.browseSortOrderDescending.lastValue, loading = false,
includedFilterItems = settings.browseIncludedFilterItems.lastValue, hideSearch = false
sortOrder = settings.browseSortOrder.lastValue )
) )
} }
@ -128,8 +123,13 @@ object BrowseScreen : Screen, Parcelable {
} }
} }
BrowseEffects(
effects = screenModel.effects,
focusRequester = focusRequester
)
BrowseContent( BrowseContent(
files = files, files = state.value.files,
selectedBooksAddDialog = state.value.selectedBooksAddDialog, selectedBooksAddDialog = state.value.selectedBooksAddDialog,
refreshState = refreshState, refreshState = refreshState,
loadingAddDialog = state.value.loadingAddDialog, loadingAddDialog = state.value.loadingAddDialog,
@ -149,7 +149,7 @@ object BrowseScreen : Screen, Parcelable {
isRefreshing = state.value.isRefreshing, isRefreshing = state.value.isRefreshing,
isLoading = state.value.isLoading, isLoading = state.value.isLoading,
dialogHidden = state.value.dialog == null, dialogHidden = state.value.dialog == null,
filesEmpty = files.isEmpty(), filesEmpty = state.value.files.isEmpty(),
showSearch = state.value.showSearch, showSearch = state.value.showSearch,
searchQuery = state.value.searchQuery, searchQuery = state.value.searchQuery,
focusRequester = focusRequester, focusRequester = focusRequester,
@ -159,24 +159,15 @@ object BrowseScreen : Screen, Parcelable {
requestFocus = screenModel::onEvent, requestFocus = screenModel::onEvent,
clearSelectedFiles = screenModel::onEvent, clearSelectedFiles = screenModel::onEvent,
selectFiles = screenModel::onEvent, selectFiles = screenModel::onEvent,
selectFile = screenModel::onEvent,
showFilterBottomSheet = screenModel::onEvent, showFilterBottomSheet = screenModel::onEvent,
dismissBottomSheet = screenModel::onEvent, dismissBottomSheet = screenModel::onEvent,
showAddDialog = screenModel::onEvent, showAddDialog = screenModel::onEvent,
dismissAddDialog = screenModel::onEvent, dismissAddDialog = screenModel::onEvent,
selectAddDialog = screenModel::onEvent, selectAddDialog = screenModel::onEvent,
actionAddDialog = screenModel::onEvent, actionAddDialog = screenModel::onEvent,
updatePinnedPaths = { updatePinnedPaths = screenModel::onEvent,
settings.browsePinnedPaths.update( navigateToLibrary = screenModel::onEvent,
settings.browsePinnedPaths.lastValue.toggle(it) navigateToBrowseSettings = screenModel::onEvent
)
},
navigateToLibrary = {
navigator.push(LibraryScreen, saveInBackStack = false)
},
navigateToBrowseSettings = {
navigator.push(BrowseSettingsScreen)
},
) )
} }
} }

View file

@ -34,8 +34,7 @@ fun BrowseAddDialog(
selectedBooksAddDialog: List<SelectableNullableBook>, selectedBooksAddDialog: List<SelectableNullableBook>,
dismissAddDialog: (BrowseEvent.OnDismissAddDialog) -> Unit, dismissAddDialog: (BrowseEvent.OnDismissAddDialog) -> Unit,
actionAddDialog: (BrowseEvent.OnActionAddDialog) -> Unit, actionAddDialog: (BrowseEvent.OnActionAddDialog) -> Unit,
selectAddDialog: (BrowseEvent.OnSelectAddDialog) -> Unit, selectAddDialog: (BrowseEvent.OnSelectAddDialog) -> Unit
navigateToLibrary: () -> Unit
) { ) {
val context = LocalContext.current val context = LocalContext.current
Dialog( Dialog(
@ -45,12 +44,7 @@ fun BrowseAddDialog(
actionEnabled = !loadingAddDialog && selectedBooksAddDialog.any { it.data is NullableBook.NotNull }, actionEnabled = !loadingAddDialog && selectedBooksAddDialog.any { it.data is NullableBook.NotNull },
onDismiss = { dismissAddDialog(BrowseEvent.OnDismissAddDialog) }, onDismiss = { dismissAddDialog(BrowseEvent.OnDismissAddDialog) },
onAction = { onAction = {
actionAddDialog( actionAddDialog(BrowseEvent.OnActionAddDialog)
BrowseEvent.OnActionAddDialog(
context = context,
navigateToLibrary = navigateToLibrary
)
)
}, },
withContent = true, withContent = true,
items = { items = {

View file

@ -16,7 +16,7 @@ fun BrowseBackHandler(
showSearch: Boolean, showSearch: Boolean,
searchVisibility: (BrowseEvent.OnSearchVisibility) -> Unit, searchVisibility: (BrowseEvent.OnSearchVisibility) -> Unit,
clearSelectedFiles: (BrowseEvent.OnClearSelectedFiles) -> Unit, clearSelectedFiles: (BrowseEvent.OnClearSelectedFiles) -> Unit,
navigateToLibrary: () -> Unit navigateToLibrary: (BrowseEvent.OnNavigateToLibrary) -> Unit
) { ) {
BackHandler { BackHandler {
if (hasSelectedItems) { if (hasSelectedItems) {
@ -29,6 +29,6 @@ fun BrowseBackHandler(
return@BackHandler return@BackHandler
} }
navigateToLibrary() navigateToLibrary(BrowseEvent.OnNavigateToLibrary)
} }
} }

View file

@ -52,16 +52,15 @@ fun BrowseContent(
requestFocus: (BrowseEvent.OnRequestFocus) -> Unit, requestFocus: (BrowseEvent.OnRequestFocus) -> Unit,
clearSelectedFiles: (BrowseEvent.OnClearSelectedFiles) -> Unit, clearSelectedFiles: (BrowseEvent.OnClearSelectedFiles) -> Unit,
selectFiles: (BrowseEvent.OnSelectFiles) -> Unit, selectFiles: (BrowseEvent.OnSelectFiles) -> Unit,
selectFile: (BrowseEvent.OnSelectFile) -> Unit,
dismissBottomSheet: (BrowseEvent.OnDismissBottomSheet) -> Unit, dismissBottomSheet: (BrowseEvent.OnDismissBottomSheet) -> Unit,
showFilterBottomSheet: (BrowseEvent.OnShowFilterBottomSheet) -> Unit, showFilterBottomSheet: (BrowseEvent.OnShowFilterBottomSheet) -> Unit,
showAddDialog: (BrowseEvent.OnShowAddDialog) -> Unit, showAddDialog: (BrowseEvent.OnShowAddDialog) -> Unit,
dismissAddDialog: (BrowseEvent.OnDismissAddDialog) -> Unit, dismissAddDialog: (BrowseEvent.OnDismissAddDialog) -> Unit,
actionAddDialog: (BrowseEvent.OnActionAddDialog) -> Unit, actionAddDialog: (BrowseEvent.OnActionAddDialog) -> Unit,
selectAddDialog: (BrowseEvent.OnSelectAddDialog) -> Unit, selectAddDialog: (BrowseEvent.OnSelectAddDialog) -> Unit,
updatePinnedPaths: (String) -> Unit, updatePinnedPaths: (BrowseEvent.OnUpdatePinnedPaths) -> Unit,
navigateToLibrary: () -> Unit, navigateToLibrary: (BrowseEvent.OnNavigateToLibrary) -> Unit,
navigateToBrowseSettings: () -> Unit, navigateToBrowseSettings: (BrowseEvent.OnNavigateToBrowseSettings) -> Unit
) { ) {
BrowseDialog( BrowseDialog(
dialog = dialog, dialog = dialog,
@ -69,8 +68,7 @@ fun BrowseContent(
actionAddDialog = actionAddDialog, actionAddDialog = actionAddDialog,
dismissAddDialog = dismissAddDialog, dismissAddDialog = dismissAddDialog,
selectedBooksAddDialog = selectedBooksAddDialog, selectedBooksAddDialog = selectedBooksAddDialog,
selectAddDialog = selectAddDialog, selectAddDialog = selectAddDialog
navigateToLibrary = navigateToLibrary
) )
BrowseBottomSheet( BrowseBottomSheet(
@ -105,7 +103,6 @@ fun BrowseContent(
selectFiles = selectFiles, selectFiles = selectFiles,
isLoading = isLoading, isLoading = isLoading,
filesEmpty = filesEmpty, filesEmpty = filesEmpty,
selectFile = selectFile,
showFilterBottomSheet = showFilterBottomSheet, showFilterBottomSheet = showFilterBottomSheet,
showAddDialog = showAddDialog, showAddDialog = showAddDialog,
updatePinnedPaths = updatePinnedPaths, updatePinnedPaths = updatePinnedPaths,

View file

@ -19,8 +19,7 @@ fun BrowseDialog(
selectedBooksAddDialog: List<SelectableNullableBook>, selectedBooksAddDialog: List<SelectableNullableBook>,
dismissAddDialog: (BrowseEvent.OnDismissAddDialog) -> Unit, dismissAddDialog: (BrowseEvent.OnDismissAddDialog) -> Unit,
actionAddDialog: (BrowseEvent.OnActionAddDialog) -> Unit, actionAddDialog: (BrowseEvent.OnActionAddDialog) -> Unit,
selectAddDialog: (BrowseEvent.OnSelectAddDialog) -> Unit, selectAddDialog: (BrowseEvent.OnSelectAddDialog) -> Unit
navigateToLibrary: () -> Unit
) { ) {
when (dialog) { when (dialog) {
BrowseScreen.ADD_DIALOG -> { BrowseScreen.ADD_DIALOG -> {
@ -29,8 +28,7 @@ fun BrowseDialog(
selectedBooksAddDialog = selectedBooksAddDialog, selectedBooksAddDialog = selectedBooksAddDialog,
dismissAddDialog = dismissAddDialog, dismissAddDialog = dismissAddDialog,
actionAddDialog = actionAddDialog, actionAddDialog = actionAddDialog,
selectAddDialog = selectAddDialog, selectAddDialog = selectAddDialog
navigateToLibrary = navigateToLibrary
) )
} }
} }

View file

@ -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<BrowseEffect>, 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)
}
}
}
}
}

View file

@ -13,6 +13,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import ua.acclorite.book_story.R 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.common.AnimatedVisibility
import ua.acclorite.book_story.ui.common.components.placeholder.EmptyPlaceholder import ua.acclorite.book_story.ui.common.components.placeholder.EmptyPlaceholder
import ua.acclorite.book_story.ui.theme.Transitions import ua.acclorite.book_story.ui.theme.Transitions
@ -23,7 +24,7 @@ fun BoxScope.BrowseEmptyPlaceholder(
dialogHidden: Boolean, dialogHidden: Boolean,
isLoading: Boolean, isLoading: Boolean,
isRefreshing: Boolean, isRefreshing: Boolean,
navigateToBrowseSettings: () -> Unit navigateToBrowseSettings: (BrowseEvent.OnNavigateToBrowseSettings) -> Unit
) { ) {
AnimatedVisibility( AnimatedVisibility(
visible = !isLoading visible = !isLoading
@ -39,7 +40,7 @@ fun BoxScope.BrowseEmptyPlaceholder(
icon = painterResource(id = R.drawable.empty_browse), icon = painterResource(id = R.drawable.empty_browse),
actionTitle = stringResource(id = R.string.set_up_scanning), actionTitle = stringResource(id = R.string.set_up_scanning),
action = { action = {
navigateToBrowseSettings() navigateToBrowseSettings(BrowseEvent.OnNavigateToBrowseSettings)
} }
) )
} }

View file

@ -53,11 +53,10 @@ fun BrowseScaffold(
requestFocus: (BrowseEvent.OnRequestFocus) -> Unit, requestFocus: (BrowseEvent.OnRequestFocus) -> Unit,
clearSelectedFiles: (BrowseEvent.OnClearSelectedFiles) -> Unit, clearSelectedFiles: (BrowseEvent.OnClearSelectedFiles) -> Unit,
selectFiles: (BrowseEvent.OnSelectFiles) -> Unit, selectFiles: (BrowseEvent.OnSelectFiles) -> Unit,
selectFile: (BrowseEvent.OnSelectFile) -> Unit,
showFilterBottomSheet: (BrowseEvent.OnShowFilterBottomSheet) -> Unit, showFilterBottomSheet: (BrowseEvent.OnShowFilterBottomSheet) -> Unit,
showAddDialog: (BrowseEvent.OnShowAddDialog) -> Unit, showAddDialog: (BrowseEvent.OnShowAddDialog) -> Unit,
updatePinnedPaths: (String) -> Unit, updatePinnedPaths: (BrowseEvent.OnUpdatePinnedPaths) -> Unit,
navigateToBrowseSettings: () -> Unit, navigateToBrowseSettings: (BrowseEvent.OnNavigateToBrowseSettings) -> Unit
) { ) {
Scaffold( Scaffold(
modifier = Modifier modifier = Modifier
@ -106,7 +105,11 @@ fun BrowseScaffold(
header = header, header = header,
pinned = pinned, pinned = pinned,
pin = { pin = {
updatePinnedPaths(header) updatePinnedPaths(
BrowseEvent.OnUpdatePinnedPaths(
path = header
)
)
} }
) )
}, },
@ -118,16 +121,14 @@ fun BrowseScaffold(
onLongClick = { onLongClick = {
selectFiles( selectFiles(
BrowseEvent.OnSelectFiles( BrowseEvent.OnSelectFiles(
includedFileFormats = includedFilterItems,
files = groupFiles files = groupFiles
) )
) )
}, },
onClick = { onClick = {
selectFile( selectFiles(
BrowseEvent.OnSelectFile( BrowseEvent.OnSelectFiles(
includedFileFormats = includedFilterItems, files = listOf(file)
file = file
) )
) )
} }

View file

@ -128,7 +128,7 @@ fun BrowseTopBar(
modifier = Modifier modifier = Modifier
.focusRequester(focusRequester) .focusRequester(focusRequester)
.onGloballyPositioned { .onGloballyPositioned {
requestFocus(BrowseEvent.OnRequestFocus(focusRequester)) requestFocus(BrowseEvent.OnRequestFocus)
}, },
initialQuery = searchQuery, initialQuery = searchQuery,
onQueryChange = { onQueryChange = {
@ -172,7 +172,6 @@ fun BrowseTopBar(
) { ) {
selectFiles( selectFiles(
BrowseEvent.OnSelectFiles( BrowseEvent.OnSelectFiles(
includedFileFormats = includedFilterItems,
files = files files = files
) )
) )