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
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<String>,
val files: List<SelectableFile>
) : BrowseEvent()
data class OnSelectFile(
val includedFileFormats: List<String>,
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()
}

View file

@ -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<BrowseEffect>()
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<NullableBook>()
_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<SelectableFile>,
sortOrderDescending: Boolean,
includedFilterItems: List<String>,
sortOrder: BrowseSortOrder
): List<SelectableFile> {
fun List<SelectableFile>.filterFiles(): List<SelectableFile> {
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 <T> MutableStateFlow<T>.update(function: (T) -> T) {
mutex.withLock {
yield()
coroutineContext.ensureActive()
this.value = function(this.value)
}
}

View file

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

View file

@ -34,8 +34,7 @@ fun BrowseAddDialog(
selectedBooksAddDialog: List<SelectableNullableBook>,
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 = {

View file

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

View file

@ -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,

View file

@ -19,8 +19,7 @@ fun BrowseDialog(
selectedBooksAddDialog: List<SelectableNullableBook>,
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
)
}
}

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.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)
}
)
}

View file

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

View file

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