refactor(settings): emit navigation and haptic actions as one-off screen effects

This commit is contained in:
2026-05-30 21:17:37 +03:00
parent a1278f7558
commit dc5b4b3fb0
7 changed files with 57 additions and 59 deletions
@@ -8,6 +8,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asFlow import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
@@ -15,6 +16,7 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlin.reflect.KClass import kotlin.reflect.KClass
import kotlin.time.Duration import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
@@ -46,6 +48,12 @@ context(viewModel: ViewModel)
fun <T> Flow<T>.listenValue(action: suspend (T) -> Unit): Job = fun <T> Flow<T>.listenValue(action: suspend (T) -> Unit): Job =
listenValue(viewModel.viewModelScope, action) listenValue(viewModel.viewModelScope, action)
context(viewModel: ViewModel)
fun <T> MutableSharedFlow<T>.emitOnMain(value: T) {
val flow = this
viewModel.viewModelScope.launch { flow.emit(value) }
}
fun <T> Flow<T>.listenValue( fun <T> Flow<T>.listenValue(
coroutineScope: CoroutineScope, coroutineScope: CoroutineScope,
action: suspend (T) -> Unit action: suspend (T) -> Unit
@@ -29,6 +29,7 @@ import dev.meloda.fast.logger.FastLogger
import dev.meloda.fast.model.database.AccountEntity import dev.meloda.fast.model.database.AccountEntity
import dev.meloda.fast.settings.model.HapticType import dev.meloda.fast.settings.model.HapticType
import dev.meloda.fast.settings.model.SettingsDialog import dev.meloda.fast.settings.model.SettingsDialog
import dev.meloda.fast.settings.model.SettingsEffect
import dev.meloda.fast.settings.model.SettingsIntent import dev.meloda.fast.settings.model.SettingsIntent
import dev.meloda.fast.settings.model.SettingsItem import dev.meloda.fast.settings.model.SettingsItem
import dev.meloda.fast.settings.model.SettingsNavigationIntent import dev.meloda.fast.settings.model.SettingsNavigationIntent
@@ -36,11 +37,10 @@ import dev.meloda.fast.settings.model.SettingsScreenState
import dev.meloda.fast.settings.model.TextProvider import dev.meloda.fast.settings.model.TextProvider
import dev.meloda.fast.ui.R import dev.meloda.fast.ui.R
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.awaitAll
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.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
class SettingsViewModel( class SettingsViewModel(
@@ -56,11 +56,8 @@ class SettingsViewModel(
private val screenState = MutableStateFlow(SettingsScreenState.EMPTY) private val screenState = MutableStateFlow(SettingsScreenState.EMPTY)
val screenStateFlow get() = screenState.asStateFlow() val screenStateFlow get() = screenState.asStateFlow()
private val hapticType = MutableStateFlow<HapticType?>(null) private val screenEffect = MutableSharedFlow<SettingsEffect>(extraBufferCapacity = 1)
val hapticTypeFlow get() = hapticType.asStateFlow() val screenEffectFlow = screenEffect.asSharedFlow()
private val navigationIntent = MutableStateFlow<SettingsNavigationIntent?>(null)
val navigationIntentFlow get() = navigationIntent.asStateFlow()
private val settings = mutableListOf<SettingsItem<*>>() private val settings = mutableListOf<SettingsItem<*>>()
private var showDebugCategory: Boolean = userSettings.showDebugCategory.value private var showDebugCategory: Boolean = userSettings.showDebugCategory.value
@@ -72,11 +69,7 @@ class SettingsViewModel(
fun handleIntent(intent: SettingsIntent) { fun handleIntent(intent: SettingsIntent) {
when (intent) { when (intent) {
SettingsIntent.BackClick -> { SettingsIntent.BackClick -> {
navigationIntent.setValue { SettingsNavigationIntent.Back } screenEffect.tryEmit(SettingsEffect.Navigate(SettingsNavigationIntent.Back))
}
SettingsIntent.ConsumePerformHaptic -> {
hapticType.setValue { null }
} }
is SettingsIntent.ItemClick -> { is SettingsIntent.ItemClick -> {
@@ -148,7 +141,10 @@ class SettingsViewModel(
UserConfig.accessToken = oldToken UserConfig.accessToken = oldToken
}, },
success = { user -> success = { user ->
if (user == null) return@listenValue if (user == null) {
UserConfig.accessToken = oldToken
return@listenValue
}
UserConfig.currentUserId = user.id UserConfig.currentUserId = user.id
@@ -169,7 +165,9 @@ class SettingsViewModel(
accountsRepository.storeAccounts(listOf(account)) accountsRepository.storeAccounts(listOf(account))
navigationIntent.setValue { SettingsNavigationIntent.Restart } screenEffect.tryEmit(
SettingsEffect.Navigate(SettingsNavigationIntent.Restart)
)
} }
) )
} }
@@ -204,26 +202,21 @@ class SettingsViewModel(
private fun onLogOutAlertPositiveClick() { private fun onLogOutAlertPositiveClick() {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
val tasks = listOf( accountsRepository.storeAccounts(
async { listOf(
accountsRepository.storeAccounts( AccountEntity(
listOf( userId = UserConfig.userId,
AccountEntity( accessToken = "",
userId = UserConfig.userId, fastToken = UserConfig.fastToken,
accessToken = "", trustedHash = UserConfig.trustedHash,
fastToken = UserConfig.fastToken, exchangeToken = null
trustedHash = UserConfig.trustedHash,
exchangeToken = null
)
)
) )
}, )
async { UserConfig.clear() }
) )
tasks.awaitAll() UserConfig.clear()
navigationIntent.setValue { SettingsNavigationIntent.LogOut } screenEffect.tryEmit(SettingsEffect.Navigate(SettingsNavigationIntent.LogOut))
} }
} }
@@ -263,7 +256,7 @@ class SettingsViewModel(
createSettings() createSettings()
hapticType.update { HapticType.REJECT } screenEffect.tryEmit(SettingsEffect.PerformHaptic(HapticType.REJECT))
showDebugCategory = false showDebugCategory = false
} }
} }
@@ -279,7 +272,7 @@ class SettingsViewModel(
createSettings() createSettings()
hapticType.update { HapticType.LONG_PRESS } screenEffect.tryEmit(SettingsEffect.PerformHaptic(HapticType.LONG_PRESS))
showDebugCategory = true showDebugCategory = true
} }
} }
@@ -0,0 +1,6 @@
package dev.meloda.fast.settings.model
sealed interface SettingsEffect {
data class Navigate(val intent: SettingsNavigationIntent) : SettingsEffect
data class PerformHaptic(val type: HapticType) : SettingsEffect
}
@@ -9,8 +9,6 @@ sealed class SettingsIntent {
data class ItemLongClick(val key: String) : SettingsIntent() data class ItemLongClick(val key: String) : SettingsIntent()
data class ItemValueChanged(val key: String, val newValue: Any?) : SettingsIntent() data class ItemValueChanged(val key: String, val newValue: Any?) : SettingsIntent()
data object ConsumePerformHaptic : SettingsIntent()
sealed class Dialog : SettingsIntent() { sealed class Dialog : SettingsIntent() {
data object Dismiss : Dialog() data object Dismiss : Dialog()
data class ConfirmClick(val bundle: Bundle? = null) : Dialog() data class ConfirmClick(val bundle: Bundle? = null) : Dialog()
@@ -2,13 +2,18 @@ package dev.meloda.fast.settings.navigation
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.platform.LocalView
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavController import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import dev.meloda.fast.datastore.AppSettings
import dev.meloda.fast.settings.SettingsViewModel import dev.meloda.fast.settings.SettingsViewModel
import dev.meloda.fast.settings.model.SettingsEffect
import dev.meloda.fast.settings.model.SettingsNavigationIntent import dev.meloda.fast.settings.model.SettingsNavigationIntent
import dev.meloda.fast.settings.presentation.SettingsRoute import dev.meloda.fast.settings.presentation.SettingsRoute
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.onEach
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import org.koin.androidx.compose.koinViewModel import org.koin.androidx.compose.koinViewModel
@@ -19,19 +24,26 @@ fun NavGraphBuilder.settingsScreen(
handleNavigationIntent: (SettingsNavigationIntent) -> Unit handleNavigationIntent: (SettingsNavigationIntent) -> Unit
) { ) {
composable<Settings> { composable<Settings> {
val view = LocalView.current
val viewModel: SettingsViewModel = koinViewModel() val viewModel: SettingsViewModel = koinViewModel()
val screenState by viewModel.screenStateFlow.collectAsStateWithLifecycle() val screenState by viewModel.screenStateFlow.collectAsStateWithLifecycle()
val hapticType by viewModel.hapticTypeFlow.collectAsStateWithLifecycle()
val navigationIntent by viewModel.navigationIntentFlow.collectAsStateWithLifecycle()
LaunchedEffect(navigationIntent) { LaunchedEffect(Unit) {
navigationIntent?.let(handleNavigationIntent) viewModel.screenEffectFlow.onEach { effect ->
when (effect) {
is SettingsEffect.Navigate -> handleNavigationIntent(effect.intent)
is SettingsEffect.PerformHaptic -> {
if (AppSettings.General.enableHaptic) {
view.performHapticFeedback(effect.type.getHaptic())
}
}
}
}.collect()
} }
SettingsRoute( SettingsRoute(
handleIntent = viewModel::handleIntent, handleIntent = viewModel::handleIntent,
screenState = screenState, screenState = screenState,
hapticType = hapticType
) )
} }
} }
@@ -1,7 +1,6 @@
package dev.meloda.fast.settings.presentation package dev.meloda.fast.settings.presentation
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import dev.meloda.fast.settings.model.HapticType
import dev.meloda.fast.settings.model.SettingsIntent import dev.meloda.fast.settings.model.SettingsIntent
import dev.meloda.fast.settings.model.SettingsScreenState import dev.meloda.fast.settings.model.SettingsScreenState
@@ -9,12 +8,10 @@ import dev.meloda.fast.settings.model.SettingsScreenState
fun SettingsRoute( fun SettingsRoute(
handleIntent: (SettingsIntent) -> Unit, handleIntent: (SettingsIntent) -> Unit,
screenState: SettingsScreenState, screenState: SettingsScreenState,
hapticType: HapticType?
) { ) {
SettingsScreen( SettingsScreen(
handleIntent = handleIntent, handleIntent = handleIntent,
screenState = screenState, screenState = screenState,
hapticType = hapticType
) )
HandleDialogs( HandleDialogs(
@@ -21,12 +21,10 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalView
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 androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
@@ -36,8 +34,6 @@ import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials import dev.chrisbanes.haze.materials.HazeMaterials
import dev.meloda.fast.datastore.AppSettings
import dev.meloda.fast.settings.model.HapticType
import dev.meloda.fast.settings.model.SettingsIntent import dev.meloda.fast.settings.model.SettingsIntent
import dev.meloda.fast.settings.model.SettingsScreenState import dev.meloda.fast.settings.model.SettingsScreenState
import dev.meloda.fast.settings.model.UiItem import dev.meloda.fast.settings.model.UiItem
@@ -58,10 +54,7 @@ import dev.meloda.fast.ui.theme.LocalThemeConfig
fun SettingsScreen( fun SettingsScreen(
handleIntent: (SettingsIntent) -> Unit, handleIntent: (SettingsIntent) -> Unit,
screenState: SettingsScreenState = SettingsScreenState.EMPTY, screenState: SettingsScreenState = SettingsScreenState.EMPTY,
hapticType: HapticType?
) { ) {
val view = LocalView.current
val onSettingsItemClicked by rememberUpdatedState { key: String -> val onSettingsItemClicked by rememberUpdatedState { key: String ->
handleIntent(SettingsIntent.ItemClick(key)) handleIntent(SettingsIntent.ItemClick(key))
} }
@@ -74,15 +67,6 @@ fun SettingsScreen(
handleIntent(SettingsIntent.ItemValueChanged(key, newValue)) handleIntent(SettingsIntent.ItemValueChanged(key, newValue))
} }
LaunchedEffect(hapticType) {
if (hapticType != null) {
if (AppSettings.General.enableHaptic) {
view.performHapticFeedback(hapticType.getHaptic())
}
handleIntent(SettingsIntent.ConsumePerformHaptic)
}
}
val themeConfig = LocalThemeConfig.current val themeConfig = LocalThemeConfig.current
val hazeState = remember { HazeState(true) } val hazeState = remember { HazeState(true) }