refactor: settings viewmodel

This commit is contained in:
Acclorite 2025-08-24 15:25:46 +03:00
parent 2ab62e629f
commit b0c567bdc9
No known key found for this signature in database
GPG key ID: 6E54C611F6EE8593
13 changed files with 359 additions and 444 deletions

View file

@ -10,7 +10,6 @@ import android.graphics.Bitmap
typealias CoverImage = Bitmap typealias CoverImage = Bitmap
typealias Selected = Boolean typealias Selected = Boolean
typealias ID = Int
typealias BottomSheet = String typealias BottomSheet = String
typealias Dialog = String typealias Dialog = String
typealias Drawer = String typealias Drawer = String

View file

@ -40,6 +40,7 @@ import ua.acclorite.book_story.ui.common.helpers.ProvideSettings
import ua.acclorite.book_story.ui.main.MainActivityKeyboardManager import ua.acclorite.book_story.ui.main.MainActivityKeyboardManager
import ua.acclorite.book_story.ui.navigator.Navigator import ua.acclorite.book_story.ui.navigator.Navigator
import ua.acclorite.book_story.ui.navigator.NavigatorTabs import ua.acclorite.book_story.ui.navigator.NavigatorTabs
import ua.acclorite.book_story.ui.settings.SettingsEffects
import ua.acclorite.book_story.ui.theme.BookStoryTheme import ua.acclorite.book_story.ui.theme.BookStoryTheme
import ua.acclorite.book_story.ui.theme.Transitions import ua.acclorite.book_story.ui.theme.Transitions
import java.lang.reflect.Field import java.lang.reflect.Field
@ -56,7 +57,7 @@ class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen().setKeepOnScreenCondition { installSplashScreen().setKeepOnScreenCondition {
!settings.initialized.value || !settingsModel.isReady.value !settings.initialized.value || !settingsModel.initialized.value
} }
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -80,6 +81,10 @@ class MainActivity : AppCompatActivity() {
val historyModel = hiltViewModel<HistoryModel>() val historyModel = hiltViewModel<HistoryModel>()
val browseModel = hiltViewModel<BrowseModel>() val browseModel = hiltViewModel<BrowseModel>()
SettingsEffects(
effects = settingsModel.effects
)
ProvideSettings(settings) { ProvideSettings(settings) {
val tabs = persistentListOf( val tabs = persistentListOf(
NavigatorItem( NavigatorItem(

View file

@ -438,8 +438,7 @@ data class ReaderScreen(val bookId: Int) : Screen, Parcelable {
letterSpacing = letterSpacing, letterSpacing = letterSpacing,
paragraphIndentation = paragraphIndentation, paragraphIndentation = paragraphIndentation,
doubleClickTranslation = settings.doubleClickTranslation.value, doubleClickTranslation = settings.doubleClickTranslation.value,
selectPreviousPreset = settingsModel::onEvent, switchColorPreset = settingsModel::onEvent,
selectNextPreset = settingsModel::onEvent,
leave = screenModel::onEvent, leave = screenModel::onEvent,
restoreCheckpoint = screenModel::onEvent, restoreCheckpoint = screenModel::onEvent,
scroll = screenModel::onEvent, scroll = screenModel::onEvent,

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.settings
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.runtime.Immutable
import ua.acclorite.book_story.domain.model.reader.ColorPreset
@Immutable
sealed class SettingsEffect {
data class OnScroll(
val listState: LazyListState,
val index: Int,
val offset: Int
) : SettingsEffect()
data class OnSwitchedColorPreset(
val newColorPreset: ColorPreset
) : SettingsEffect()
}

View file

@ -6,11 +6,8 @@
package ua.acclorite.book_story.presentation.settings package ua.acclorite.book_story.presentation.settings
import android.content.Context
import android.net.Uri
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import ua.acclorite.book_story.core.ID
import ua.acclorite.book_story.core.language.Language import ua.acclorite.book_story.core.language.Language
import ua.acclorite.book_story.domain.model.library.Category import ua.acclorite.book_story.domain.model.library.Category
import ua.acclorite.book_story.presentation.library.model.LibrarySortOrder import ua.acclorite.book_story.presentation.library.model.LibrarySortOrder
@ -22,11 +19,11 @@ sealed class SettingsEvent {
) : SettingsEvent() ) : SettingsEvent()
data class OnGrantPersistableUriPermission( data class OnGrantPersistableUriPermission(
val uri: Uri val uri: String
) : SettingsEvent() ) : SettingsEvent()
data class OnReleasePersistableUriPermission( data class OnReleasePersistableUriPermission(
val uri: Uri val uri: String
) : SettingsEvent() ) : SettingsEvent()
data class OnCreateCategory( data class OnCreateCategory(
@ -53,28 +50,24 @@ sealed class SettingsEvent {
) : SettingsEvent() ) : SettingsEvent()
data class OnSelectColorPreset( data class OnSelectColorPreset(
val id: ID val id: Int,
) : SettingsEvent() ) : SettingsEvent()
data class OnSelectPreviousPreset( data class OnSwitchColorPreset(
val context: Context val previous: Boolean
) : SettingsEvent()
data class OnSelectNextPreset(
val context: Context
) : SettingsEvent() ) : SettingsEvent()
data class OnDeleteColorPreset( data class OnDeleteColorPreset(
val id: ID val id: Int
) : SettingsEvent() ) : SettingsEvent()
data class OnUpdateColorPresetTitle( data class OnUpdateColorPresetTitle(
val id: ID, val id: Int,
val title: String val title: String
) : SettingsEvent() ) : SettingsEvent()
data class OnShuffleColorPreset( data class OnShuffleColorPreset(
val id: ID val id: Int
) : SettingsEvent() ) : SettingsEvent()
data class OnAddColorPreset( data class OnAddColorPreset(
@ -82,20 +75,16 @@ sealed class SettingsEvent {
val fontColor: Color val fontColor: Color
) : SettingsEvent() ) : SettingsEvent()
data class OnUpdateColorPresetColor(
val id: Int,
val backgroundColor: Color?,
val fontColor: Color?
) : SettingsEvent()
data class OnReorderColorPresets( data class OnReorderColorPresets(
val from: Int, val from: Int,
val to: Int val to: Int
) : SettingsEvent() ) : SettingsEvent()
data class OnUpdateColorPresetColor(
val id: ID,
val backgroundColor: Color?,
val fontColor: Color?
) : SettingsEvent()
data object OnConfirmReorderColorPresets : SettingsEvent() data object OnConfirmReorderColorPresets : SettingsEvent()
data class OnScrollToColorPreset(
val index: Int
) : SettingsEvent()
} }

View file

@ -6,20 +6,20 @@
package ua.acclorite.book_story.presentation.settings package ua.acclorite.book_story.presentation.settings
import android.util.Log
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel 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.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.launch 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.domain.model.library.CategorySort import ua.acclorite.book_story.domain.model.library.CategorySort
import ua.acclorite.book_story.domain.model.reader.ColorPreset import ua.acclorite.book_story.domain.model.reader.ColorPreset
import ua.acclorite.book_story.domain.use_case.category.AddCategoryUseCase import ua.acclorite.book_story.domain.use_case.category.AddCategoryUseCase
@ -37,8 +37,8 @@ import ua.acclorite.book_story.domain.use_case.color_preset.UpdateColorPresetUse
import ua.acclorite.book_story.domain.use_case.permission.GrantPersistableUriPermissionUseCase import ua.acclorite.book_story.domain.use_case.permission.GrantPersistableUriPermissionUseCase
import ua.acclorite.book_story.domain.use_case.permission.ReleasePersistableUriPermissionUseCase import ua.acclorite.book_story.domain.use_case.permission.ReleasePersistableUriPermissionUseCase
import ua.acclorite.book_story.domain.use_case.settings.UpdateLanguageUseCase import ua.acclorite.book_story.domain.use_case.settings.UpdateLanguageUseCase
import ua.acclorite.book_story.ui.common.helpers.showToast
import javax.inject.Inject import javax.inject.Inject
import kotlin.coroutines.coroutineContext
import kotlin.random.Random import kotlin.random.Random
@HiltViewModel @HiltViewModel
@ -65,75 +65,64 @@ class SettingsModel @Inject constructor(
private val _state = MutableStateFlow(SettingsState()) private val _state = MutableStateFlow(SettingsState())
val state = _state.asStateFlow() val state = _state.asStateFlow()
private val _isReady = MutableStateFlow(false) private val _effects = MutableSharedFlow<SettingsEffect>()
val isReady = _isReady.asStateFlow() val effects = _effects.asSharedFlow()
private var selectColorPresetJob: Job? = null private val _initialized = MutableStateFlow(false)
private var addColorPresetJob: Job? = null val initialized = _initialized.asStateFlow()
private var deleteColorPresetJob: Job? = null
private var updateColorColorPresetJob: Job? = null private var colorPresetJob: Job? = null
private var updateTitleColorPresetJob: Job? = null
private var shuffleColorPresetJob: Job? = null
init { init {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch {
var colorPresets = getColorPresetsUseCase() var colorPresets = getColorPresetsUseCase()
if (colorPresets.isEmpty()) { if (colorPresets.isEmpty()) {
updateColorPresetUseCase(ColorPreset.default) updateColorPresetUseCase(ColorPreset.default)
getColorPresetsUseCase().first().select() getColorPresetsUseCase().first().selectColorPreset()
colorPresets = getColorPresetsUseCase() colorPresets = getColorPresetsUseCase()
} }
val scrollIndex = colorPresets.indexOfFirst { val scrollIndex = colorPresets.indexOfFirst { it.isSelected }
it.isSelected
}
if (scrollIndex != -1) { if (scrollIndex != -1) {
launch(Dispatchers.Main) { _effects.emit(
try { SettingsEffect.OnScroll(
_state.value.colorPresetListState.requestScrollToItem(index = scrollIndex) listState = _state.value.colorPresetListState,
} catch (e: Exception) { index = scrollIndex,
e.printStackTrace() offset = 0
} )
} )
} }
_state.update { _state.update {
it.copy( it.copy(
selectedColorPreset = colorPresets.selected(), selectedColorPreset = colorPresets.getSelectedColorPreset(),
colorPresets = colorPresets, colorPresets = colorPresets,
categories = getCategoriesUseCase(), // Getting categories categories = getCategoriesUseCase(),
categoriesSort = getCategorySortingUseCase() categoriesSort = getCategorySortingUseCase()
) )
} }
Log.i("SETTINGS", "SettingsModel is ready.") _initialized.update { true }
_isReady.update { true }
} }
} }
fun onEvent(event: SettingsEvent) { fun onEvent(event: SettingsEvent) {
when (event) { viewModelScope.launch {
is SettingsEvent.OnUpdateLanguage -> updateLanguageUseCase(event.language) when (event) {
is SettingsEvent.OnUpdateLanguage -> {
is SettingsEvent.OnGrantPersistableUriPermission -> { updateLanguageUseCase(event.language)
viewModelScope.launch {
grantPersistableUriPermissionUseCase(
event.uri.toString()
)
} }
}
is SettingsEvent.OnReleasePersistableUriPermission -> { is SettingsEvent.OnGrantPersistableUriPermission -> {
viewModelScope.launch { grantPersistableUriPermissionUseCase(event.uri)
releasePersistableUriPermissionUseCase(
event.uri.toString()
)
} }
}
is SettingsEvent.OnCreateCategory -> { is SettingsEvent.OnReleasePersistableUriPermission -> {
viewModelScope.launch { releasePersistableUriPermissionUseCase(event.uri)
}
is SettingsEvent.OnCreateCategory -> {
addCategoryUseCase(event.title) addCategoryUseCase(event.title)
_state.update { _state.update {
it.copy( it.copy(
@ -141,10 +130,8 @@ class SettingsModel @Inject constructor(
) )
} }
} }
}
is SettingsEvent.OnUpdateCategoryTitle -> { is SettingsEvent.OnUpdateCategoryTitle -> {
viewModelScope.launch {
updateCategoryUseCase( updateCategoryUseCase(
categoryId = event.id, categoryId = event.id,
newTitle = event.title newTitle = event.title
@ -155,10 +142,8 @@ class SettingsModel @Inject constructor(
) )
} }
} }
}
is SettingsEvent.OnUpdateCategoryOrder -> { is SettingsEvent.OnUpdateCategoryOrder -> {
viewModelScope.launch {
updateCategoriesOrderUseCase( updateCategoriesOrderUseCase(
categories = event.categories categories = event.categories
) )
@ -168,10 +153,8 @@ class SettingsModel @Inject constructor(
) )
} }
} }
}
is SettingsEvent.OnRemoveCategory -> { is SettingsEvent.OnRemoveCategory -> {
viewModelScope.launch {
deleteCategoryUseCase( deleteCategoryUseCase(
category = event.category category = event.category
) )
@ -181,10 +164,8 @@ class SettingsModel @Inject constructor(
) )
} }
} }
}
is SettingsEvent.OnUpdateCategorySort -> { is SettingsEvent.OnUpdateCategorySort -> {
viewModelScope.launch {
updateCategorySortingUseCase( updateCategorySortingUseCase(
categorySort = CategorySort( categorySort = CategorySort(
categoryId = event.categoryId, categoryId = event.categoryId,
@ -198,359 +179,268 @@ class SettingsModel @Inject constructor(
) )
} }
} }
}
is SettingsEvent.OnSelectColorPreset -> { is SettingsEvent.OnSelectColorPreset -> {
viewModelScope.launch { withContext(Dispatchers.IO) {
cancelColorPresetJobs() colorPresetJob?.join()
selectColorPresetJob = launch { colorPresetJob = viewModelScope.launch(Dispatchers.Default) {
val colorPreset = _state.value.colorPresets.getColorPresetById(event.id) val colorPreset = _state.value.colorPresets.getColorPresetById(event.id)
?: return@launch ?: return@launch
yield() ensureActive()
colorPreset.select(animate = true) colorPreset.selectColorPreset(animate = true)
val colorPresets = _state.value.colorPresets.map { val colorPresets = _state.value.colorPresets.map {
it.copy(isSelected = colorPreset.id == it.id) it.copy(isSelected = colorPreset.id == it.id)
} }
_state.update { _state.update {
it.copy( it.copy(
selectedColorPreset = colorPresets.selected(), selectedColorPreset = colorPresets.getSelectedColorPreset(),
colorPresets = colorPresets colorPresets = colorPresets
) )
}
} }
} }
} }
}
is SettingsEvent.OnSelectPreviousPreset -> { is SettingsEvent.OnSwitchColorPreset -> {
viewModelScope.launch { withContext(Dispatchers.IO) {
cancelColorPresetJobs() colorPresetJob?.join()
selectColorPresetJob = launch { colorPresetJob = viewModelScope.launch(Dispatchers.Default) {
val colorPresets = _state.value.colorPresets val colorPresets = _state.value.colorPresets
val selectedPreset = _state.value.selectedColorPreset if (colorPresets.size == 1) return@launch
if (colorPresets.size == 1) { val selectedPreset = _state.value.selectedColorPreset
return@launch val selectedPresetIndex = colorPresets.indexOf(selectedPreset)
} if (selectedPresetIndex == -1) return@launch
val selectedPresetIndex = colorPresets.indexOf(selectedPreset) val newColorPresetIndex = if (event.previous) {
if (selectedPresetIndex == -1) { when (selectedPresetIndex) {
return@launch 0 -> colorPresets.lastIndex
} else -> selectedPresetIndex - 1
}
} else {
when (selectedPresetIndex) {
colorPresets.lastIndex -> 0
else -> selectedPresetIndex + 1
}
}
val newColorPreset = colorPresets.getOrNull(newColorPresetIndex)
?: return@launch
val previousColorPresetIndex = when (selectedPresetIndex) { ensureActive()
0 -> {
colorPresets.lastIndex newColorPreset.selectColorPreset()
val updatedColorPresets = _state.value.colorPresets.map {
it.copy(isSelected = newColorPreset.id == it.id)
}
_state.update {
it.copy(
selectedColorPreset = updatedColorPresets.getSelectedColorPreset(),
colorPresets = updatedColorPresets
)
} }
else -> { _effects.emit(SettingsEffect.OnSwitchedColorPreset(newColorPreset))
selectedPresetIndex - 1
}
} }
val previousColorPreset = colorPresets.getOrNull(previousColorPresetIndex) }
?: return@launch }
yield() is SettingsEvent.OnDeleteColorPreset -> {
withContext(Dispatchers.IO) {
colorPresetJob?.join()
colorPresetJob = viewModelScope.launch(Dispatchers.Default) {
if (_state.value.colorPresets.size == 1) return@launch
val colorPreset = _state.value.colorPresets.getColorPresetById(event.id)
?: return@launch
previousColorPreset.select() val position = _state.value.colorPresets.indexOf(colorPreset)
val updatedColorPresets = _state.value.colorPresets.map { if (position == -1) return@launch
it.copy(isSelected = previousColorPreset.id == it.id)
}
_state.update {
it.copy(
selectedColorPreset = updatedColorPresets.selected(),
colorPresets = updatedColorPresets
)
}
withContext(Dispatchers.Main) { val nextPosition =
event.context.getString( if (position == _state.value.colorPresets.lastIndex) {
R.string.color_preset_selected_query, position - 1
if (previousColorPreset.name.isNullOrBlank()) {
event.context.getString(
R.string.color_preset_query,
previousColorPreset.id.toString()
)
} else { } else {
previousColorPreset.name position
}.trim() }
).showToast(event.context, longToast = false)
ensureActive()
deleteColorPresetUseCase(colorPreset)
val nextColorPreset = getColorPresetsUseCase().getOrNull(nextPosition)
?: return@launch
nextColorPreset.selectColorPreset()
val colorPresets = getColorPresetsUseCase()
_state.update {
it.copy(
selectedColorPreset = colorPresets.getSelectedColorPreset(),
colorPresets = colorPresets
)
}
_effects.emit(
SettingsEffect.OnScroll(
listState = _state.value.colorPresetListState,
index = nextPosition,
offset = 0
)
)
} }
} }
} }
}
is SettingsEvent.OnSelectNextPreset -> { is SettingsEvent.OnUpdateColorPresetTitle -> {
viewModelScope.launch { withContext(Dispatchers.IO) {
cancelColorPresetJobs() colorPresetJob?.join()
selectColorPresetJob = launch { colorPresetJob = viewModelScope.launch(Dispatchers.Default) {
val colorPresets = _state.value.colorPresets val colorPreset = _state.value.colorPresets.getColorPresetById(event.id)
val selectedPreset = _state.value.selectedColorPreset ?: return@launch
val updatedColorPreset = colorPreset.copy(name = event.title)
if (colorPresets.size == 1) { ensureActive()
return@launch
}
val selectedPresetIndex = colorPresets.indexOf(selectedPreset) updateColorPresetUseCase(updatedColorPreset)
if (selectedPresetIndex == -1) { _state.update {
return@launch it.copy(
} selectedColorPreset = updatedColorPreset,
colorPresets = it.colorPresets.updateColorPreset(
val nextColorPresetIndex = when (selectedPresetIndex) { updatedColorPreset
colorPresets.lastIndex -> {
0
}
else -> {
selectedPresetIndex + 1
}
}
val nextColorPreset = colorPresets.getOrNull(nextColorPresetIndex)
?: return@launch
yield()
nextColorPreset.select()
val updatedColorPresets = _state.value.colorPresets.map {
it.copy(isSelected = nextColorPreset.id == it.id)
}
_state.update {
it.copy(
selectedColorPreset = updatedColorPresets.selected(),
colorPresets = updatedColorPresets
)
}
withContext(Dispatchers.Main) {
event.context.getString(
R.string.color_preset_selected_query,
if (nextColorPreset.name.isNullOrBlank()) {
event.context.getString(
R.string.color_preset_query,
nextColorPreset.id.toString()
) )
} else { )
nextColorPreset.name }
}.trim()
).showToast(event.context, longToast = false)
} }
} }
} }
}
is SettingsEvent.OnDeleteColorPreset -> { is SettingsEvent.OnShuffleColorPreset -> {
viewModelScope.launch { withContext(Dispatchers.IO) {
cancelColorPresetJobs() colorPresetJob?.join()
deleteColorPresetJob = launch { colorPresetJob = viewModelScope.launch(Dispatchers.Default) {
if (_state.value.colorPresets.size == 1) return@launch val colorPreset = _state.value.colorPresets.getColorPresetById(event.id)
val colorPreset = _state.value.colorPresets.getColorPresetById(event.id) ?: return@launch
?: return@launch val shuffledColorPreset = colorPreset.copy(
backgroundColor = colorPreset.backgroundColor.copy(
yield() red = Random.nextFloat(),
green = Random.nextFloat(),
val position = _state.value.colorPresets.indexOf(colorPreset) blue = Random.nextFloat()
if (position == -1) { ),
return@launch fontColor = colorPreset.fontColor.copy(
} red = Random.nextFloat(),
green = Random.nextFloat(),
val nextPosition = if (position == _state.value.colorPresets.lastIndex) { blue = Random.nextFloat()
position - 1
} else {
position
}
yield()
deleteColorPresetUseCase(colorPreset)
val nextColorPreset = getColorPresetsUseCase().getOrNull(nextPosition)
?: return@launch
nextColorPreset.select()
val colorPresets = getColorPresetsUseCase()
_state.update {
it.copy(
selectedColorPreset = colorPresets.selected(),
colorPresets = colorPresets
)
}
onEvent(
SettingsEvent.OnScrollToColorPreset(
index = nextPosition
)
)
}
}
}
is SettingsEvent.OnUpdateColorPresetTitle -> {
viewModelScope.launch {
cancelColorPresetJobs()
updateTitleColorPresetJob = launch {
val colorPreset = _state.value.colorPresets.getColorPresetById(event.id)
?: return@launch
yield()
val updatedColorPreset = colorPreset.copy(
name = event.title
)
yield()
updateColorPresetUseCase(updatedColorPreset)
_state.update {
it.copy(
selectedColorPreset = updatedColorPreset,
colorPresets = it.colorPresets.updateColorPreset(
updatedColorPreset
) )
) )
}
}
}
}
is SettingsEvent.OnShuffleColorPreset -> { ensureActive()
viewModelScope.launch {
cancelColorPresetJobs()
shuffleColorPresetJob = launch {
val colorPreset = _state.value.colorPresets.getColorPresetById(event.id)
?: return@launch
yield() updateColorPresetUseCase(shuffledColorPreset)
_state.update {
val shuffledColorPreset = colorPreset.copy( it.copy(
backgroundColor = colorPreset.backgroundColor.copy( selectedColorPreset = shuffledColorPreset,
red = Random.nextFloat(), colorPresets = it.colorPresets.updateColorPreset(
green = Random.nextFloat(), shuffledColorPreset
blue = Random.nextFloat() )
),
fontColor = colorPreset.fontColor.copy(
red = Random.nextFloat(),
green = Random.nextFloat(),
blue = Random.nextFloat()
)
)
yield()
updateColorPresetUseCase(shuffledColorPreset)
_state.update {
it.copy(
selectedColorPreset = shuffledColorPreset,
colorPresets = it.colorPresets.updateColorPreset(
shuffledColorPreset
) )
) }
} }
} }
} }
}
is SettingsEvent.OnAddColorPreset -> { is SettingsEvent.OnAddColorPreset -> {
viewModelScope.launch { withContext(Dispatchers.IO) {
cancelColorPresetJobs() colorPresetJob?.join()
addColorPresetJob = launch { colorPresetJob = viewModelScope.launch(Dispatchers.Default) {
yield() val newColorPreset = ColorPreset.default.copy(
backgroundColor = event.backgroundColor,
val newColorPreset = ColorPreset.default.copy( fontColor = event.fontColor
backgroundColor = event.backgroundColor,
fontColor = event.fontColor
)
updateColorPresetUseCase(newColorPreset)
getColorPresetsUseCase().last().select()
val colorPresets = getColorPresetsUseCase()
_state.update {
it.copy(
selectedColorPreset = colorPresets.selected(),
colorPresets = colorPresets
) )
}
onEvent(SettingsEvent.OnScrollToColorPreset(colorPresets.lastIndex)) ensureActive()
}
}
}
is SettingsEvent.OnUpdateColorPresetColor -> { updateColorPresetUseCase(newColorPreset)
viewModelScope.launch { getColorPresetsUseCase().last().selectColorPreset()
cancelColorPresetJobs() val colorPresets = getColorPresetsUseCase()
updateColorColorPresetJob = launch {
val colorPreset = _state.value.colorPresets.getColorPresetById(event.id)
?: return@launch
yield() _state.update {
it.copy(
val updatedColorPreset = colorPreset.copy( selectedColorPreset = colorPresets.getSelectedColorPreset(),
backgroundColor = event.backgroundColor colorPresets = colorPresets
?: colorPreset.backgroundColor,
fontColor = event.fontColor
?: colorPreset.fontColor
)
yield()
updateColorPresetUseCase(updatedColorPreset)
_state.update {
it.copy(
selectedColorPreset = updatedColorPreset,
colorPresets = it.colorPresets.updateColorPreset(
updatedColorPreset
) )
)
}
}
}
}
is SettingsEvent.OnReorderColorPresets -> {
viewModelScope.launch {
cancelColorPresetJobs()
launch {
val reorderedColorPresets = _state.value.colorPresets
.toMutableList()
.apply {
add(event.to, removeAt(event.from))
} }
_state.update { _effects.emit(
it.copy( SettingsEffect.OnScroll(
colorPresets = reorderedColorPresets listState = _state.value.colorPresetListState,
index = colorPresets.lastIndex,
offset = 0
)
) )
} }
} }
} }
}
is SettingsEvent.OnConfirmReorderColorPresets -> { is SettingsEvent.OnUpdateColorPresetColor -> {
viewModelScope.launch { withContext(Dispatchers.IO) {
cancelColorPresetJobs() colorPresetJob?.join()
launch { colorPresetJob = viewModelScope.launch(Dispatchers.Default) {
reorderColorPresetsUseCase(_state.value.colorPresets) val colorPreset = _state.value.colorPresets.getColorPresetById(event.id)
?: return@launch
val updatedColorPreset = colorPreset.copy(
backgroundColor = event.backgroundColor
?: colorPreset.backgroundColor,
fontColor = event.fontColor
?: colorPreset.fontColor
)
ensureActive()
updateColorPresetUseCase(updatedColorPreset)
_state.update {
it.copy(
selectedColorPreset = updatedColorPreset,
colorPresets = it.colorPresets.updateColorPreset(
updatedColorPreset
)
)
}
}
} }
} }
}
is SettingsEvent.OnScrollToColorPreset -> { is SettingsEvent.OnReorderColorPresets -> {
viewModelScope.launch { withContext(Dispatchers.IO) {
try { colorPresetJob?.join()
_state.value.colorPresetListState.requestScrollToItem( withContext(Dispatchers.Default) {
index = event.index val reorderedColorPresets = _state.value.colorPresets
) .toMutableList()
} catch (e: Exception) { .also { colorPresets ->
e.printStackTrace() colorPresets.add(event.to, colorPresets.removeAt(event.from))
}
_state.update {
it.copy(
colorPresets = reorderedColorPresets
)
}
}
}
}
is SettingsEvent.OnConfirmReorderColorPresets -> {
withContext(Dispatchers.IO) {
colorPresetJob?.join()
withContext(Dispatchers.Default) {
reorderColorPresetsUseCase(_state.value.colorPresets)
}
} }
} }
} }
} }
} }
private suspend fun ColorPreset.select(animate: Boolean = false) { private suspend fun ColorPreset.selectColorPreset(animate: Boolean = false) {
selectColorPresetUseCase(this) selectColorPresetUseCase(this)
_state.update { _state.update {
it.copy( it.copy(
@ -560,53 +450,28 @@ class SettingsModel @Inject constructor(
} }
private fun List<ColorPreset>.getColorPresetById(id: Int): ColorPreset? { private fun List<ColorPreset>.getColorPresetById(id: Int): ColorPreset? {
return firstOrNull { return find {
it.id == id it.id == id
} }
} }
private fun List<ColorPreset>?.selected(): ColorPreset { private fun List<ColorPreset>.getSelectedColorPreset(): ColorPreset {
val presets = this ?: _state.value.colorPresets if (size == 1) return first()
find { it.isSelected }?.also { return it }
if (presets.size == 1) { return ColorPreset.default
return presets.first()
}
val selectedPreset = presets.firstOrNull { it.isSelected }
if (selectedPreset == null) {
return ColorPreset.default
}
return selectedPreset
} }
private fun List<ColorPreset>.updateColorPreset(colorPreset: ColorPreset): List<ColorPreset> { private fun List<ColorPreset>.updateColorPreset(colorPreset: ColorPreset): List<ColorPreset> {
if (size == 1) { if (size == 1) return listOf(colorPreset)
return listOf(colorPreset) return map {
if (it.isSelected) colorPreset
else it
} }
return this.map {
if (it.isSelected) {
colorPreset
} else {
it
}
}
}
private fun cancelColorPresetJobs() {
selectColorPresetJob?.cancel()
addColorPresetJob?.cancel()
updateTitleColorPresetJob?.cancel()
shuffleColorPresetJob?.cancel()
updateColorColorPresetJob?.cancel()
deleteColorPresetJob?.cancel()
} }
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

@ -28,11 +28,10 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.lerp import androidx.compose.ui.graphics.lerp
import ua.acclorite.book_story.core.ID
import ua.acclorite.book_story.ui.common.components.common.AnimatedVisibility import ua.acclorite.book_story.ui.common.components.common.AnimatedVisibility
data class TopAppBarData( data class TopAppBarData(
val contentID: ID, val contentID: Int,
val contentNavigationIcon: @Composable () -> Unit, val contentNavigationIcon: @Composable () -> Unit,
val contentTitle: @Composable () -> Unit, val contentTitle: @Composable () -> Unit,
val contentActions: @Composable RowScope.() -> Unit val contentActions: @Composable RowScope.() -> Unit
@ -46,7 +45,7 @@ data class TopAppBarData(
* @param scrolledContainerColor Scrolled container color of the TopBar. * @param scrolledContainerColor Scrolled container color of the TopBar.
* @param scrollBehavior [TopAppBarScrollBehavior]. * @param scrollBehavior [TopAppBarScrollBehavior].
* @param isTopBarScrolled Whether isScrolled state should be forced or not. * @param isTopBarScrolled Whether isScrolled state should be forced or not.
* @param shownTopBar [ID] of the top bar to show. * @param shownTopBar ID of the top bar to show.
* @param topBars Pass a list of all [TopAppBarData] to show. * @param topBars Pass a list of all [TopAppBarData] to show.
* @param customContent Custom content below [TopAppBar]. * @param customContent Custom content below [TopAppBar].
*/ */
@ -59,7 +58,7 @@ fun TopAppBar(
scrollBehavior: TopAppBarScrollBehavior?, scrollBehavior: TopAppBarScrollBehavior?,
isTopBarScrolled: Boolean?, isTopBarScrolled: Boolean?,
shownTopBar: ID, shownTopBar: Int,
topBars: List<TopAppBarData>, topBars: List<TopAppBarData>,
customContent: @Composable ColumnScope.() -> Unit = {} customContent: @Composable ColumnScope.() -> Unit = {}
) { ) {

View file

@ -12,17 +12,14 @@ import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import ua.acclorite.book_story.presentation.settings.SettingsEvent import ua.acclorite.book_story.presentation.settings.SettingsEvent
@Composable @Composable
fun Modifier.readerColorPresetChange( fun Modifier.readerColorPresetChange(
colorPresetChangeEnabled: Boolean, colorPresetChangeEnabled: Boolean,
isLoading: Boolean, isLoading: Boolean,
selectPreviousPreset: (SettingsEvent.OnSelectPreviousPreset) -> Unit, switchColorPreset: (SettingsEvent.OnSwitchColorPreset) -> Unit
selectNextPreset: (SettingsEvent.OnSelectNextPreset) -> Unit
): Modifier { ): Modifier {
val context = LocalContext.current
val offset = remember { mutableFloatStateOf(0f) } val offset = remember { mutableFloatStateOf(0f) }
return this.then( return this.then(
if (colorPresetChangeEnabled && !isLoading) { if (colorPresetChangeEnabled && !isLoading) {
@ -33,17 +30,17 @@ fun Modifier.readerColorPresetChange(
onDragEnd = { onDragEnd = {
when { when {
offset.floatValue > 200 -> { offset.floatValue > 200 -> {
selectPreviousPreset( switchColorPreset(
SettingsEvent.OnSelectPreviousPreset( SettingsEvent.OnSwitchColorPreset(
context = context previous = true
) )
) )
} }
offset.floatValue < -200 -> { offset.floatValue < -200 -> {
selectNextPreset( switchColorPreset(
SettingsEvent.OnSelectNextPreset( SettingsEvent.OnSwitchColorPreset(
context = context previous = false
) )
) )
} }

View file

@ -88,8 +88,7 @@ fun ReaderContent(
letterSpacing: TextUnit, letterSpacing: TextUnit,
paragraphIndentation: TextUnit, paragraphIndentation: TextUnit,
doubleClickTranslation: Boolean, doubleClickTranslation: Boolean,
selectPreviousPreset: (SettingsEvent.OnSelectPreviousPreset) -> Unit, switchColorPreset: (SettingsEvent.OnSwitchColorPreset) -> Unit,
selectNextPreset: (SettingsEvent.OnSelectNextPreset) -> Unit,
menuVisibility: (ReaderEvent.OnMenuVisibility) -> Unit, menuVisibility: (ReaderEvent.OnMenuVisibility) -> Unit,
leave: (ReaderEvent.OnLeave) -> Unit, leave: (ReaderEvent.OnLeave) -> Unit,
restoreCheckpoint: (ReaderEvent.OnRestoreCheckpoint) -> Unit, restoreCheckpoint: (ReaderEvent.OnRestoreCheckpoint) -> Unit,
@ -165,8 +164,7 @@ fun ReaderContent(
letterSpacing = letterSpacing, letterSpacing = letterSpacing,
paragraphIndentation = paragraphIndentation, paragraphIndentation = paragraphIndentation,
doubleClickTranslation = doubleClickTranslation, doubleClickTranslation = doubleClickTranslation,
selectPreviousPreset = selectPreviousPreset, switchColorPreset = switchColorPreset,
selectNextPreset = selectNextPreset,
menuVisibility = menuVisibility, menuVisibility = menuVisibility,
leave = leave, leave = leave,
restoreCheckpoint = restoreCheckpoint, restoreCheckpoint = restoreCheckpoint,

View file

@ -92,8 +92,7 @@ fun ReaderScaffold(
letterSpacing: TextUnit, letterSpacing: TextUnit,
paragraphIndentation: TextUnit, paragraphIndentation: TextUnit,
doubleClickTranslation: Boolean, doubleClickTranslation: Boolean,
selectPreviousPreset: (SettingsEvent.OnSelectPreviousPreset) -> Unit, switchColorPreset: (SettingsEvent.OnSwitchColorPreset) -> Unit,
selectNextPreset: (SettingsEvent.OnSelectNextPreset) -> Unit,
menuVisibility: (ReaderEvent.OnMenuVisibility) -> Unit, menuVisibility: (ReaderEvent.OnMenuVisibility) -> Unit,
leave: (ReaderEvent.OnLeave) -> Unit, leave: (ReaderEvent.OnLeave) -> Unit,
restoreCheckpoint: (ReaderEvent.OnRestoreCheckpoint) -> Unit, restoreCheckpoint: (ReaderEvent.OnRestoreCheckpoint) -> Unit,
@ -127,8 +126,7 @@ fun ReaderScaffold(
isLoading = isLoading, isLoading = isLoading,
lockMenu = lockMenu, lockMenu = lockMenu,
leave = leave, leave = leave,
selectPreviousPreset = selectPreviousPreset, switchColorPreset = switchColorPreset,
selectNextPreset = selectNextPreset,
showSettingsBottomSheet = showSettingsBottomSheet, showSettingsBottomSheet = showSettingsBottomSheet,
showChaptersDrawer = showChaptersDrawer, showChaptersDrawer = showChaptersDrawer,
navigateBack = navigateBack, navigateBack = navigateBack,

View file

@ -49,8 +49,7 @@ fun ReaderTopBar(
isLoading: Boolean, isLoading: Boolean,
lockMenu: Boolean, lockMenu: Boolean,
leave: (ReaderEvent.OnLeave) -> Unit, leave: (ReaderEvent.OnLeave) -> Unit,
selectPreviousPreset: (SettingsEvent.OnSelectPreviousPreset) -> Unit, switchColorPreset: (SettingsEvent.OnSwitchColorPreset) -> Unit,
selectNextPreset: (SettingsEvent.OnSelectNextPreset) -> Unit,
showSettingsBottomSheet: (ReaderEvent.OnShowSettingsBottomSheet) -> Unit, showSettingsBottomSheet: (ReaderEvent.OnShowSettingsBottomSheet) -> Unit,
showChaptersDrawer: (ReaderEvent.OnShowChaptersDrawer) -> Unit, showChaptersDrawer: (ReaderEvent.OnShowChaptersDrawer) -> Unit,
navigateToBookInfo: (ReaderEvent.OnNavigateToBookInfo) -> Unit, navigateToBookInfo: (ReaderEvent.OnNavigateToBookInfo) -> Unit,
@ -68,8 +67,7 @@ fun ReaderTopBar(
.readerColorPresetChange( .readerColorPresetChange(
colorPresetChangeEnabled = fastColorPresetChange, colorPresetChangeEnabled = fastColorPresetChange,
isLoading = isLoading, isLoading = isLoading,
selectPreviousPreset = selectPreviousPreset, switchColorPreset = switchColorPreset
selectNextPreset = selectNextPreset
) )
) { ) {
TopAppBar( TopAppBar(

View file

@ -0,0 +1,44 @@
/*
* 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.settings
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.platform.LocalContext
import kotlinx.coroutines.flow.SharedFlow
import ua.acclorite.book_story.R
import ua.acclorite.book_story.presentation.settings.SettingsEffect
import ua.acclorite.book_story.ui.common.helpers.showToast
@Composable
fun SettingsEffects(effects: SharedFlow<SettingsEffect>) {
val context = LocalContext.current
LaunchedEffect(effects) {
effects.collect { effect ->
when (effect) {
is SettingsEffect.OnScroll -> {
effect.listState.requestScrollToItem(effect.index, effect.offset)
}
is SettingsEffect.OnSwitchedColorPreset -> {
context.getString(
R.string.color_preset_selected_query,
if (effect.newColorPreset.name.isNullOrBlank()) {
context.getString(
R.string.color_preset_query,
effect.newColorPreset.id.toString()
)
} else {
effect.newColorPreset.name
}.trim()
).showToast(context, longToast = false)
}
}
}
}
}

View file

@ -73,7 +73,7 @@ fun BrowseScanOption() {
if (uri == null) return@rememberLauncherForActivityResult if (uri == null) return@rememberLauncherForActivityResult
settingsModel.onEvent( settingsModel.onEvent(
SettingsEvent.OnGrantPersistableUriPermission( SettingsEvent.OnGrantPersistableUriPermission(
uri = uri uri = uri.toString()
) )
) )
@ -95,7 +95,7 @@ fun BrowseScanOption() {
releasePersistableUriPermission = { releasePersistableUriPermission = {
settingsModel.onEvent( settingsModel.onEvent(
SettingsEvent.OnReleasePersistableUriPermission( SettingsEvent.OnReleasePersistableUriPermission(
uri = permission.uri uri = permission.uri.toString()
) )
) )