Don't use mutable state flows in UI (#4336)

This commit is contained in:
Zongle Wang 2024-03-27 18:17:42 +08:00 committed by GitHub
parent 3274bd2660
commit a3d87de8ac
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 117 additions and 85 deletions

View File

@ -1040,7 +1040,7 @@ class ComposeActivity :
override fun onVisibilityChanged(visibility: Status.Visibility) {
composeOptionsBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
viewModel.statusVisibility.value = visibility
viewModel.changeStatusVisibility(visibility)
}
@VisibleForTesting

View File

@ -45,7 +45,9 @@ import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.flow.update
@ -86,16 +88,25 @@ class ComposeViewModel @Inject constructor(
val emoji: SharedFlow<List<Emoji>> = instanceInfoRepo::getEmojis.asFlow()
.shareIn(viewModelScope, SharingStarted.Eagerly, replay = 1)
val markMediaAsSensitive: MutableStateFlow<Boolean> =
private val _markMediaAsSensitive =
MutableStateFlow(accountManager.activeAccount?.defaultMediaSensitivity ?: false)
val markMediaAsSensitive: StateFlow<Boolean> = _markMediaAsSensitive.asStateFlow()
val statusVisibility: MutableStateFlow<Status.Visibility> =
MutableStateFlow(Status.Visibility.UNKNOWN)
val showContentWarning: MutableStateFlow<Boolean> = MutableStateFlow(false)
val poll: MutableStateFlow<NewPoll?> = MutableStateFlow(null)
val scheduledAt: MutableStateFlow<String?> = MutableStateFlow(null)
private val _statusVisibility = MutableStateFlow(Status.Visibility.UNKNOWN)
val statusVisibility: StateFlow<Status.Visibility> = _statusVisibility.asStateFlow()
private val _showContentWarning = MutableStateFlow(false)
val showContentWarning: StateFlow<Boolean> = _showContentWarning.asStateFlow()
private val _poll = MutableStateFlow(null as NewPoll?)
val poll: StateFlow<NewPoll?> = _poll.asStateFlow()
private val _scheduledAt = MutableStateFlow(null as String?)
val scheduledAt: StateFlow<String?> = _scheduledAt.asStateFlow()
private val _media = MutableStateFlow(emptyList<QueuedMedia>())
val media: StateFlow<List<QueuedMedia>> = _media.asStateFlow()
val media: MutableStateFlow<List<QueuedMedia>> = MutableStateFlow(emptyList())
val uploadError =
MutableSharedFlow<Throwable>(
replay = 0,
@ -103,7 +114,8 @@ class ComposeViewModel @Inject constructor(
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
val closeConfirmation = MutableStateFlow(ConfirmationKind.NONE)
private val _closeConfirmation = MutableStateFlow(ConfirmationKind.NONE)
val closeConfirmation: StateFlow<ConfirmationKind> = _closeConfirmation.asStateFlow()
private lateinit var composeKind: ComposeKind
@ -121,7 +133,7 @@ class ComposeViewModel @Inject constructor(
) {
try {
val (type, uri, size) = mediaUploader.prepareMedia(mediaUri, instanceInfo.first())
val mediaItems = media.value
val mediaItems = _media.value
if (type != QueuedMedia.Type.IMAGE &&
mediaItems.isNotEmpty() &&
mediaItems[0].type == QueuedMedia.Type.IMAGE
@ -146,7 +158,7 @@ class ComposeViewModel @Inject constructor(
): QueuedMedia {
var stashMediaItem: QueuedMedia? = null
media.update { mediaList ->
_media.update { mediaList ->
val mediaItem = QueuedMedia(
localId = mediaUploader.getNewLocalMediaId(),
uri = uri,
@ -173,7 +185,7 @@ class ComposeViewModel @Inject constructor(
mediaUploader
.uploadMedia(mediaItem, instanceInfo.first())
.collect { event ->
val item = media.value.find { it.localId == mediaItem.localId }
val item = _media.value.find { it.localId == mediaItem.localId }
?: return@collect
val newMediaItem = when (event) {
is UploadEvent.ProgressEvent ->
@ -189,12 +201,12 @@ class ComposeViewModel @Inject constructor(
}
)
is UploadEvent.ErrorEvent -> {
media.update { mediaList -> mediaList.filter { it.localId != mediaItem.localId } }
_media.update { mediaList -> mediaList.filter { it.localId != mediaItem.localId } }
uploadError.emit(event.error)
return@collect
}
}
media.update { mediaList ->
_media.update { mediaList ->
mediaList.map { mediaItem ->
if (mediaItem.localId == newMediaItem.localId) {
newMediaItem
@ -209,6 +221,10 @@ class ComposeViewModel @Inject constructor(
return mediaItem
}
fun changeStatusVisibility(visibility: Status.Visibility) {
_statusVisibility.value = visibility
}
private fun addUploadedMedia(
id: String,
type: QueuedMedia.Type,
@ -216,7 +232,7 @@ class ComposeViewModel @Inject constructor(
description: String?,
focus: Attachment.Focus?
) {
media.update { mediaList ->
_media.update { mediaList ->
val mediaItem = QueuedMedia(
localId = mediaUploader.getNewLocalMediaId(),
uri = uri,
@ -234,12 +250,12 @@ class ComposeViewModel @Inject constructor(
fun removeMediaFromQueue(item: QueuedMedia) {
mediaUploader.cancelUploadScope(item.localId)
media.update { mediaList -> mediaList.filter { it.localId != item.localId } }
_media.update { mediaList -> mediaList.filter { it.localId != item.localId } }
updateCloseConfirmation()
}
fun toggleMarkSensitive() {
this.markMediaAsSensitive.value = this.markMediaAsSensitive.value != true
this._markMediaAsSensitive.value = this._markMediaAsSensitive.value != true
}
fun updateContent(newContent: String?) {
@ -253,12 +269,12 @@ class ComposeViewModel @Inject constructor(
}
private fun updateCloseConfirmation() {
val contentWarning = if (showContentWarning.value) {
val contentWarning = if (_showContentWarning.value) {
currentContentWarning
} else {
""
}
this.closeConfirmation.value = if (didChange(currentContent, contentWarning)) {
this._closeConfirmation.value = if (didChange(currentContent, contentWarning)) {
when (composeKind) {
ComposeKind.NEW -> if (isEmpty(currentContent, contentWarning)) {
ConfirmationKind.NONE
@ -281,19 +297,19 @@ class ComposeViewModel @Inject constructor(
private fun didChange(content: String?, contentWarning: String?): Boolean {
val textChanged = content.orEmpty() != startingText.orEmpty()
val contentWarningChanged = contentWarning.orEmpty() != startingContentWarning
val mediaChanged = media.value.isNotEmpty()
val pollChanged = poll.value != null
val mediaChanged = _media.value.isNotEmpty()
val pollChanged = _poll.value != null
val didScheduledTimeChange = hasScheduledTimeChanged
return modifiedInitialState || textChanged || contentWarningChanged || mediaChanged || pollChanged || didScheduledTimeChange
}
private fun isEmpty(content: String?, contentWarning: String?): Boolean {
return !modifiedInitialState && (content.isNullOrBlank() && contentWarning.isNullOrBlank() && media.value.isEmpty() && poll.value == null)
return !modifiedInitialState && (content.isNullOrBlank() && contentWarning.isNullOrBlank() && _media.value.isEmpty() && _poll.value == null)
}
fun contentWarningChanged(value: Boolean) {
showContentWarning.value = value
_showContentWarning.value = value
contentWarningStateChanged = true
updateCloseConfirmation()
}
@ -307,12 +323,12 @@ class ComposeViewModel @Inject constructor(
}
fun stopUploads() {
mediaUploader.cancelUploadScope(*media.value.map { it.localId }.toIntArray())
mediaUploader.cancelUploadScope(*_media.value.map { it.localId }.toIntArray())
}
fun shouldShowSaveDraftDialog(): Boolean {
// if any of the media files need to be downloaded first it could take a while, so show a loading dialog
return media.value.any { mediaValue ->
return _media.value.any { mediaValue ->
mediaValue.uri.scheme == "https"
}
}
@ -321,7 +337,7 @@ class ComposeViewModel @Inject constructor(
val mediaUris: MutableList<String> = mutableListOf()
val mediaDescriptions: MutableList<String?> = mutableListOf()
val mediaFocus: MutableList<Attachment.Focus?> = mutableListOf()
for (item in media.value) {
for (item in _media.value) {
mediaUris.add(item.uri.toString())
mediaDescriptions.add(item.description)
mediaFocus.add(item.focus)
@ -333,15 +349,15 @@ class ComposeViewModel @Inject constructor(
inReplyToId = inReplyToId,
content = content,
contentWarning = contentWarning,
sensitive = markMediaAsSensitive.value,
visibility = statusVisibility.value,
sensitive = _markMediaAsSensitive.value,
visibility = _statusVisibility.value,
mediaUris = mediaUris,
mediaDescriptions = mediaDescriptions,
mediaFocus = mediaFocus,
poll = poll.value,
poll = _poll.value,
failedToSend = false,
failedToSendAlert = false,
scheduledAt = scheduledAt.value,
scheduledAt = _scheduledAt.value,
language = postLanguage,
statusId = originalStatusId
)
@ -356,7 +372,7 @@ class ComposeViewModel @Inject constructor(
api.deleteScheduledStatus(scheduledTootId!!)
}
val attachedMedia = media.value.map { item ->
val attachedMedia = _media.value.map { item ->
MediaToSend(
localId = item.localId,
id = item.id,
@ -369,12 +385,12 @@ class ComposeViewModel @Inject constructor(
val tootToSend = StatusToSend(
text = content,
warningText = spoilerText,
visibility = statusVisibility.value.serverString(),
sensitive = attachedMedia.isNotEmpty() && (markMediaAsSensitive.value || showContentWarning.value),
visibility = _statusVisibility.value.serverString(),
sensitive = attachedMedia.isNotEmpty() && (_markMediaAsSensitive.value || _showContentWarning.value),
media = attachedMedia,
scheduledAt = scheduledAt.value,
scheduledAt = _scheduledAt.value,
inReplyToId = inReplyToId,
poll = poll.value,
poll = _poll.value,
replyingStatusContent = null,
replyingStatusAuthorUsername = null,
accountId = accountId,
@ -389,7 +405,7 @@ class ComposeViewModel @Inject constructor(
}
private fun updateMediaItem(localId: Int, mutator: (QueuedMedia) -> QueuedMedia) {
media.update { mediaList ->
_media.update { mediaList ->
mediaList.map { mediaItem ->
if (mediaItem.localId == localId) {
mutator(mediaItem)
@ -478,7 +494,7 @@ class ComposeViewModel @Inject constructor(
startingContentWarning = contentWarning
}
if (!contentWarningStateChanged) {
showContentWarning.value = !contentWarning.isNullOrBlank()
_showContentWarning.value = !contentWarning.isNullOrBlank()
}
// recreate media list
@ -512,7 +528,7 @@ class ComposeViewModel @Inject constructor(
if (tootVisibility.num != Status.Visibility.UNKNOWN.num) {
startingVisibility = tootVisibility
}
statusVisibility.value = startingVisibility
_statusVisibility.value = startingVisibility
val mentionedUsernames = composeOptions?.mentionedUsernames
if (mentionedUsernames != null) {
val builder = StringBuilder()
@ -524,13 +540,13 @@ class ComposeViewModel @Inject constructor(
startingText = builder.toString()
}
scheduledAt.value = composeOptions?.scheduledAt
_scheduledAt.value = composeOptions?.scheduledAt
composeOptions?.sensitive?.let { markMediaAsSensitive.value = it }
composeOptions?.sensitive?.let { _markMediaAsSensitive.value = it }
val poll = composeOptions?.poll
if (poll != null && composeOptions.mediaAttachments.isNullOrEmpty()) {
this.poll.value = poll
this._poll.value = poll
}
replyingStatusContent = composeOptions?.replyingStatusContent
replyingStatusAuthor = composeOptions?.replyingStatusAuthor
@ -541,16 +557,16 @@ class ComposeViewModel @Inject constructor(
}
fun updatePoll(newPoll: NewPoll?) {
poll.value = newPoll
_poll.value = newPoll
updateCloseConfirmation()
}
fun updateScheduledAt(newScheduledAt: String?) {
if (newScheduledAt != scheduledAt.value) {
if (newScheduledAt != _scheduledAt.value) {
hasScheduledTimeChanged = true
}
scheduledAt.value = newScheduledAt
_scheduledAt.value = newScheduledAt
}
val editing: Boolean

View File

@ -11,79 +11,91 @@ import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.isHttpNotFound
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.withContext
class EditFilterViewModel @Inject constructor(val api: MastodonApi, val eventHub: EventHub) : ViewModel() {
private var originalFilter: Filter? = null
val title = MutableStateFlow("")
val keywords = MutableStateFlow(listOf<FilterKeyword>())
val action = MutableStateFlow(Filter.Action.WARN)
val duration = MutableStateFlow(0)
val contexts = MutableStateFlow(listOf<Filter.Kind>())
private val _title = MutableStateFlow("")
val title: StateFlow<String> = _title.asStateFlow()
private val _keywords = MutableStateFlow(listOf<FilterKeyword>())
val keywords: StateFlow<List<FilterKeyword>> = _keywords.asStateFlow()
private val _action = MutableStateFlow(Filter.Action.WARN)
val action: StateFlow<Filter.Action> = _action.asStateFlow()
private val _duration = MutableStateFlow(0)
val duration: StateFlow<Int> = _duration.asStateFlow()
private val _contexts = MutableStateFlow(listOf<Filter.Kind>())
val contexts: StateFlow<List<Filter.Kind>> = _contexts.asStateFlow()
fun load(filter: Filter) {
originalFilter = filter
title.value = filter.title
keywords.value = filter.keywords
action.value = filter.action
duration.value = if (filter.expiresAt == null) {
_title.value = filter.title
_keywords.value = filter.keywords
_action.value = filter.action
_duration.value = if (filter.expiresAt == null) {
0
} else {
-1
}
contexts.value = filter.kinds
_contexts.value = filter.kinds
}
fun addKeyword(keyword: FilterKeyword) {
keywords.value += keyword
_keywords.value += keyword
}
fun deleteKeyword(keyword: FilterKeyword) {
keywords.value = keywords.value.filterNot { it == keyword }
_keywords.value = _keywords.value.filterNot { it == keyword }
}
fun modifyKeyword(original: FilterKeyword, updated: FilterKeyword) {
val index = keywords.value.indexOf(original)
val index = _keywords.value.indexOf(original)
if (index >= 0) {
keywords.value = keywords.value.toMutableList().apply {
_keywords.value = _keywords.value.toMutableList().apply {
set(index, updated)
}
}
}
fun setTitle(title: String) {
this.title.value = title
this._title.value = title
}
fun setDuration(index: Int) {
duration.value = index
_duration.value = index
}
fun setAction(action: Filter.Action) {
this.action.value = action
this._action.value = action
}
fun addContext(context: Filter.Kind) {
if (!contexts.value.contains(context)) {
contexts.value += context
if (!_contexts.value.contains(context)) {
_contexts.value += context
}
}
fun removeContext(context: Filter.Kind) {
contexts.value = contexts.value.filter { it != context }
_contexts.value = _contexts.value.filter { it != context }
}
fun validate(): Boolean {
return title.value.isNotBlank() &&
keywords.value.isNotEmpty() &&
contexts.value.isNotEmpty()
return _title.value.isNotBlank() &&
_keywords.value.isNotEmpty() &&
_contexts.value.isNotEmpty()
}
suspend fun saveChanges(context: Context): Boolean {
val contexts = contexts.value.map { it.kind }
val title = title.value
val durationIndex = duration.value
val action = action.value.action
val contexts = _contexts.value.map { it.kind }
val title = _title.value
val durationIndex = _duration.value
val action = _action.value.action
return withContext(viewModelScope.coroutineContext) {
originalFilter?.let { filter ->
@ -108,7 +120,7 @@ class EditFilterViewModel @Inject constructor(val api: MastodonApi, val eventHub
).fold(
{ newFilter ->
// This is _terrible_, but the all-in-one update filter api Just Doesn't Work
return keywords.value.map { keyword ->
return _keywords.value.map { keyword ->
api.addFilterKeyword(
filterId = newFilter.id,
keyword = keyword.keyword,
@ -144,7 +156,7 @@ class EditFilterViewModel @Inject constructor(val api: MastodonApi, val eventHub
).fold(
{
// This is _terrible_, but the all-in-one update filter api Just Doesn't Work
val results = keywords.value.map { keyword ->
val results = _keywords.value.map { keyword ->
if (keyword.id.isEmpty()) {
api.addFilterKeyword(filterId = originalFilter.id, keyword = keyword.keyword, wholeWord = keyword.wholeWord)
} else {
@ -152,7 +164,7 @@ class EditFilterViewModel @Inject constructor(val api: MastodonApi, val eventHub
}
} + originalFilter.keywords.filter { keyword ->
// Deleted keywords
keywords.value.none { it.id == keyword.id }
_keywords.value.none { it.id == keyword.id }
}.map { api.deleteFilterKeyword(it.id) }
return results.none { it.isFailure }
@ -170,13 +182,13 @@ class EditFilterViewModel @Inject constructor(val api: MastodonApi, val eventHub
}
private suspend fun createFilterV1(context: List<String>, expiresInSeconds: Int?): Boolean {
return keywords.value.map { keyword ->
return _keywords.value.map { keyword ->
api.createFilterV1(keyword.keyword, context, false, keyword.wholeWord, expiresInSeconds)
}.none { it.isFailure }
}
private suspend fun updateFilterV1(context: List<String>, expiresInSeconds: Int?): Boolean {
val results = keywords.value.map { keyword ->
val results = _keywords.value.map { keyword ->
if (originalFilter == null) {
api.createFilterV1(
phrase = keyword.keyword,

View File

@ -23,13 +23,15 @@ import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.isHttpNotFound
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
class LoginWebViewViewModel @Inject constructor(
private val api: MastodonApi
) : ViewModel() {
val instanceRules: MutableStateFlow<List<String>> = MutableStateFlow(emptyList())
private val _instanceRules = MutableStateFlow(emptyList<String>())
val instanceRules = _instanceRules.asStateFlow()
private var domain: String? = null
@ -39,13 +41,13 @@ class LoginWebViewViewModel @Inject constructor(
viewModelScope.launch {
api.getInstance().fold(
{ instance ->
instanceRules.value = instance.rules.orEmpty().map { rule -> rule.text }
_instanceRules.value = instance.rules.orEmpty().map { rule -> rule.text }
},
{ throwable ->
if (throwable.isHttpNotFound()) {
api.getInstanceV1(domain).fold(
{ instance ->
instanceRules.value = instance.rules?.map { rule -> rule.text }.orEmpty()
_instanceRules.value = instance.rules?.map { rule -> rule.text }.orEmpty()
},
{ throwable ->
Log.w(

View File

@ -51,6 +51,7 @@ import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
@ -64,9 +65,8 @@ class ViewThreadViewModel @Inject constructor(
private val gson: Gson
) : ViewModel() {
private val _uiState: MutableStateFlow<ThreadUiState> = MutableStateFlow(ThreadUiState.Loading)
val uiState: Flow<ThreadUiState>
get() = _uiState
private val _uiState = MutableStateFlow(ThreadUiState.Loading as ThreadUiState)
val uiState: Flow<ThreadUiState> = _uiState.asStateFlow()
private val _errors =
MutableSharedFlow<Throwable>(

View File

@ -45,7 +45,7 @@ import org.pageseeder.xmlwriter.XMLStringWriter
class ViewEditsViewModel @Inject constructor(private val api: MastodonApi) : ViewModel() {
private val _uiState: MutableStateFlow<EditsUiState> = MutableStateFlow(EditsUiState.Initial)
private val _uiState = MutableStateFlow(EditsUiState.Initial as EditsUiState)
val uiState: StateFlow<EditsUiState> = _uiState.asStateFlow()
/** The API call to fetch edit history returned less than two items */

View File

@ -41,6 +41,7 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.launch
import okhttp3.MediaType.Companion.toMediaTypeOrNull
@ -73,7 +74,8 @@ class EditProfileViewModel @Inject constructor(
val instanceData: Flow<InstanceInfo> = instanceInfoRepo::getUpdatedInstanceInfoOrFallback.asFlow()
.shareIn(viewModelScope, SharingStarted.Eagerly, replay = 1)
val isChanged = MutableStateFlow(false)
private val _isChanged = MutableStateFlow(false)
val isChanged = _isChanged.asStateFlow()
private var apiProfileAccount: Account? = null
@ -106,7 +108,7 @@ class EditProfileViewModel @Inject constructor(
}
internal fun dataChanged(newProfileData: ProfileDataInUi) {
isChanged.value = getProfileDiff(apiProfileAccount, newProfileData).hasChanges()
_isChanged.value = getProfileDiff(apiProfileAccount, newProfileData).hasChanges()
}
internal fun save(newProfileData: ProfileDataInUi) {