Migrate LiveData to Flow (#4337)

This commit is contained in:
Zongle Wang 2024-03-27 18:34:17 +08:00 committed by GitHub
parent a3d87de8ac
commit f029b7f84d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 326 additions and 274 deletions

View File

@ -30,7 +30,6 @@ import androidx.activity.viewModels
import androidx.appcompat.app.AlertDialog
import androidx.core.view.isVisible
import androidx.core.widget.doAfterTextChanged
import androidx.lifecycle.LiveData
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import com.bumptech.glide.Glide
@ -59,6 +58,7 @@ import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
import com.mikepenz.iconics.utils.colorInt
import com.mikepenz.iconics.utils.sizeDp
import javax.inject.Inject
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
class EditProfileActivity : BaseActivity(), Injectable {
@ -152,51 +152,54 @@ class EditProfileActivity : BaseActivity(), Injectable {
viewModel.obtainProfile()
viewModel.profileData.observe(this) { profileRes ->
when (profileRes) {
is Success -> {
val me = profileRes.data
if (me != null) {
binding.displayNameEditText.setText(me.displayName)
binding.noteEditText.setText(me.source?.note)
binding.lockedCheckBox.isChecked = me.locked
lifecycleScope.launch {
viewModel.profileData.collect { profileRes ->
if (profileRes == null) return@collect
when (profileRes) {
is Success -> {
val me = profileRes.data
if (me != null) {
binding.displayNameEditText.setText(me.displayName)
binding.noteEditText.setText(me.source?.note)
binding.lockedCheckBox.isChecked = me.locked
accountFieldEditAdapter.setFields(me.source?.fields.orEmpty())
binding.addFieldButton.isVisible =
(me.source?.fields?.size ?: 0) < maxAccountFields
accountFieldEditAdapter.setFields(me.source?.fields.orEmpty())
binding.addFieldButton.isVisible =
(me.source?.fields?.size ?: 0) < maxAccountFields
if (viewModel.avatarData.value == null) {
Glide.with(this)
.load(me.avatar)
.placeholder(R.drawable.avatar_default)
.transform(
FitCenter(),
RoundedCorners(
resources.getDimensionPixelSize(R.dimen.avatar_radius_80dp)
if (viewModel.avatarData.value == null) {
Glide.with(this@EditProfileActivity)
.load(me.avatar)
.placeholder(R.drawable.avatar_default)
.transform(
FitCenter(),
RoundedCorners(
resources.getDimensionPixelSize(R.dimen.avatar_radius_80dp)
)
)
)
.into(binding.avatarPreview)
}
.into(binding.avatarPreview)
}
if (viewModel.headerData.value == null) {
Glide.with(this)
.load(me.header)
.into(binding.headerPreview)
if (viewModel.headerData.value == null) {
Glide.with(this@EditProfileActivity)
.load(me.header)
.into(binding.headerPreview)
}
}
}
is Error -> {
Snackbar.make(
binding.avatarButton,
R.string.error_generic,
Snackbar.LENGTH_LONG
)
.setAction(R.string.action_retry) {
viewModel.obtainProfile()
}
.show()
}
is Loading -> { }
}
is Error -> {
Snackbar.make(
binding.avatarButton,
R.string.error_generic,
Snackbar.LENGTH_LONG
)
.setAction(R.string.action_retry) {
viewModel.obtainProfile()
}
.show()
}
is Loading -> { }
}
}
@ -215,18 +218,19 @@ class EditProfileActivity : BaseActivity(), Injectable {
observeImage(viewModel.avatarData, binding.avatarPreview, true)
observeImage(viewModel.headerData, binding.headerPreview, false)
viewModel.saveData.observe(
this
) {
when (it) {
is Success -> {
finish()
}
is Loading -> {
binding.saveProgressBar.visibility = View.VISIBLE
}
is Error -> {
onSaveFailure(it.errorMessage)
lifecycleScope.launch {
viewModel.saveData.collect {
if (it == null) return@collect
when (it) {
is Success -> {
finish()
}
is Loading -> {
binding.saveProgressBar.visibility = View.VISIBLE
}
is Error -> {
onSaveFailure(it.errorMessage)
}
}
}
}
@ -269,30 +273,30 @@ class EditProfileActivity : BaseActivity(), Injectable {
}
private fun observeImage(
liveData: LiveData<Uri>,
flow: StateFlow<Uri?>,
imageView: ImageView,
roundedCorners: Boolean
) {
liveData.observe(
this
) { imageUri ->
lifecycleScope.launch {
flow.collect { imageUri ->
// skipping all caches so we can always reuse the same uri
val glide = Glide.with(imageView)
.load(imageUri)
.skipMemoryCache(true)
.diskCacheStrategy(DiskCacheStrategy.NONE)
// skipping all caches so we can always reuse the same uri
val glide = Glide.with(imageView)
.load(imageUri)
.skipMemoryCache(true)
.diskCacheStrategy(DiskCacheStrategy.NONE)
if (roundedCorners) {
glide.transform(
FitCenter(),
RoundedCorners(resources.getDimensionPixelSize(R.dimen.avatar_radius_80dp))
).into(imageView)
} else {
glide.into(imageView)
if (roundedCorners) {
glide.transform(
FitCenter(),
RoundedCorners(resources.getDimensionPixelSize(R.dimen.avatar_radius_80dp))
).into(imageView)
} else {
glide.into(imageView)
}
imageView.show()
}
imageView.show()
}
}

View File

@ -48,6 +48,7 @@ import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsCompat.Type.systemBars
import androidx.core.view.updatePadding
import androidx.core.widget.doAfterTextChanged
import androidx.lifecycle.lifecycleScope
import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.viewpager2.widget.MarginPageTransformer
@ -109,6 +110,7 @@ import java.text.SimpleDateFormat
import java.util.Locale
import javax.inject.Inject
import kotlin.math.abs
import kotlinx.coroutines.launch
class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider, HasAndroidInjector, LinkListener {
@ -429,10 +431,32 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide
* Subscribe to data loaded at the view model
*/
private fun subscribeObservables() {
viewModel.accountData.observe(this) {
when (it) {
is Success -> onAccountChanged(it.data)
is Error -> {
lifecycleScope.launch {
viewModel.accountData.collect {
if (it == null) return@collect
when (it) {
is Success -> onAccountChanged(it.data)
is Error -> {
Snackbar.make(
binding.accountCoordinatorLayout,
R.string.error_generic,
Snackbar.LENGTH_LONG
)
.setAction(R.string.action_retry) { viewModel.refresh() }
.show()
}
is Loading -> { }
}
}
}
lifecycleScope.launch {
viewModel.relationshipData.collect {
val relation = it?.data
if (relation != null) {
onRelationshipChanged(relation)
}
if (it is Error) {
Snackbar.make(
binding.accountCoordinatorLayout,
R.string.error_generic,
@ -441,27 +465,12 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide
.setAction(R.string.action_retry) { viewModel.refresh() }
.show()
}
is Loading -> { }
}
}
viewModel.relationshipData.observe(this) {
val relation = it?.data
if (relation != null) {
onRelationshipChanged(relation)
lifecycleScope.launch {
viewModel.noteSaved.collect {
binding.saveNoteInfo.visible(it, View.INVISIBLE)
}
if (it is Error) {
Snackbar.make(
binding.accountCoordinatorLayout,
R.string.error_generic,
Snackbar.LENGTH_LONG
)
.setAction(R.string.action_retry) { viewModel.refresh() }
.show()
}
}
viewModel.noteSaved.observe(this) {
binding.saveNoteInfo.visible(it, View.INVISIBLE)
}
// "Post failed" dialog should display in this activity
@ -478,10 +487,10 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide
*/
private fun setupRefreshLayout() {
binding.swipeToRefreshLayout.setOnRefreshListener { onRefresh() }
viewModel.isRefreshing.observe(
this
) { isRefreshing ->
binding.swipeToRefreshLayout.isRefreshing = isRefreshing == true
lifecycleScope.launch {
viewModel.isRefreshing.collect { isRefreshing ->
binding.swipeToRefreshLayout.isRefreshing = isRefreshing == true
}
}
binding.swipeToRefreshLayout.setColorSchemeResources(R.color.tusky_blue)
}

View File

@ -1,7 +1,6 @@
package com.keylesspalace.tusky.components.account
import android.util.Log
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import at.connyduck.calladapter.networkresult.fold
@ -23,6 +22,9 @@ import com.keylesspalace.tusky.util.getDomain
import javax.inject.Inject
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
class AccountViewModel @Inject constructor(
@ -31,12 +33,18 @@ class AccountViewModel @Inject constructor(
accountManager: AccountManager
) : ViewModel() {
val accountData = MutableLiveData<Resource<Account>>()
val relationshipData = MutableLiveData<Resource<Relationship>>()
private val _accountData = MutableStateFlow(null as Resource<Account>?)
val accountData: StateFlow<Resource<Account>?> = _accountData.asStateFlow()
val noteSaved = MutableLiveData<Boolean>()
private val _relationshipData = MutableStateFlow(null as Resource<Relationship>?)
val relationshipData: StateFlow<Resource<Relationship>?> = _relationshipData.asStateFlow()
private val _noteSaved = MutableStateFlow(false)
val noteSaved: StateFlow<Boolean> = _noteSaved.asStateFlow()
private val _isRefreshing = MutableStateFlow(false)
val isRefreshing: StateFlow<Boolean> = _isRefreshing.asStateFlow()
val isRefreshing = MutableLiveData<Boolean>().apply { value = false }
private var isDataLoading = false
lateinit var accountId: String
@ -55,17 +63,17 @@ class AccountViewModel @Inject constructor(
init {
viewModelScope.launch {
eventHub.events.collect { event ->
if (event is ProfileEditedEvent && event.newProfileData.id == accountData.value?.data?.id) {
accountData.postValue(Success(event.newProfileData))
if (event is ProfileEditedEvent && event.newProfileData.id == _accountData.value?.data?.id) {
_accountData.value = Success(event.newProfileData)
}
}
}
}
private fun obtainAccount(reload: Boolean = false) {
if (accountData.value == null || reload) {
if (_accountData.value == null || reload) {
isDataLoading = true
accountData.postValue(Loading())
_accountData.value = Loading()
viewModelScope.launch {
mastodonApi.account(accountId)
@ -74,15 +82,15 @@ class AccountViewModel @Inject constructor(
domain = getDomain(account.url)
isFromOwnDomain = domain == activeAccount.domain
accountData.postValue(Success(account))
_accountData.value = Success(account)
isDataLoading = false
isRefreshing.postValue(false)
_isRefreshing.value = false
},
{ t ->
Log.w(TAG, "failed obtaining account", t)
accountData.postValue(Error(cause = t))
_accountData.value = Error(cause = t)
isDataLoading = false
isRefreshing.postValue(false)
_isRefreshing.value = false
}
)
}
@ -90,14 +98,14 @@ class AccountViewModel @Inject constructor(
}
private fun obtainRelationship(reload: Boolean = false) {
if (relationshipData.value == null || reload) {
relationshipData.postValue(Loading())
if (_relationshipData.value == null || reload) {
_relationshipData.value = Loading()
viewModelScope.launch {
mastodonApi.relationships(listOf(accountId))
.fold(
{ relationships ->
relationshipData.postValue(
_relationshipData.value =
if (relationships.isNotEmpty()) {
Success(
relationships[0]
@ -105,11 +113,10 @@ class AccountViewModel @Inject constructor(
} else {
Error()
}
)
},
{ t ->
Log.w(TAG, "failed obtaining relationships", t)
relationshipData.postValue(Error(cause = t))
_relationshipData.value = Error(cause = t)
}
)
}
@ -117,7 +124,7 @@ class AccountViewModel @Inject constructor(
}
fun changeFollowState() {
val relationship = relationshipData.value?.data
val relationship = _relationshipData.value?.data
if (relationship?.following == true || relationship?.requested == true) {
changeRelationship(RelationShipAction.UNFOLLOW)
} else {
@ -126,7 +133,7 @@ class AccountViewModel @Inject constructor(
}
fun changeBlockState() {
if (relationshipData.value?.data?.blocking == true) {
if (_relationshipData.value?.data?.blocking == true) {
changeRelationship(RelationShipAction.UNBLOCK)
} else {
changeRelationship(RelationShipAction.BLOCK)
@ -142,7 +149,7 @@ class AccountViewModel @Inject constructor(
}
fun changeSubscribingState() {
val relationship = relationshipData.value?.data
val relationship = _relationshipData.value?.data
if (relationship?.notifying == true || // Mastodon 3.3.0rc1
relationship?.subscribing == true // Pleroma
) {
@ -156,9 +163,9 @@ class AccountViewModel @Inject constructor(
viewModelScope.launch {
mastodonApi.blockDomain(instance).fold({
eventHub.dispatch(DomainMuteEvent(instance))
val relation = relationshipData.value?.data
val relation = _relationshipData.value?.data
if (relation != null) {
relationshipData.postValue(Success(relation.copy(blockingDomain = true)))
_relationshipData.value = Success(relation.copy(blockingDomain = true))
}
}, { e ->
Log.e(TAG, "Error muting $instance", e)
@ -169,9 +176,9 @@ class AccountViewModel @Inject constructor(
fun unblockDomain(instance: String) {
viewModelScope.launch {
mastodonApi.unblockDomain(instance).fold({
val relation = relationshipData.value?.data
val relation = _relationshipData.value?.data
if (relation != null) {
relationshipData.postValue(Success(relation.copy(blockingDomain = false)))
_relationshipData.value = Success(relation.copy(blockingDomain = false))
}
}, { e ->
Log.e(TAG, "Error unmuting $instance", e)
@ -180,7 +187,7 @@ class AccountViewModel @Inject constructor(
}
fun changeShowReblogsState() {
if (relationshipData.value?.data?.showingReblogs == true) {
if (_relationshipData.value?.data?.showingReblogs == true) {
changeRelationship(RelationShipAction.FOLLOW, false)
} else {
changeRelationship(RelationShipAction.FOLLOW, true)
@ -195,9 +202,9 @@ class AccountViewModel @Inject constructor(
parameter: Boolean? = null,
duration: Int? = null
) = viewModelScope.launch {
val relation = relationshipData.value?.data
val account = accountData.value?.data
val isMastodon = relationshipData.value?.data?.notifying != null
val relation = _relationshipData.value?.data
val account = _accountData.value?.data
val isMastodon = _relationshipData.value?.data?.notifying != null
if (relation != null && account != null) {
// optimistically post new state for faster response
@ -230,7 +237,7 @@ class AccountViewModel @Inject constructor(
}
}
}
relationshipData.postValue(Loading(newRelation))
_relationshipData.value = Loading(newRelation)
}
val relationshipCall = when (relationshipAction) {
@ -265,7 +272,7 @@ class AccountViewModel @Inject constructor(
relationshipCall.fold(
{ relationship ->
relationshipData.postValue(Success(relationship))
_relationshipData.value = Success(relationship)
when (relationshipAction) {
RelationShipAction.UNFOLLOW -> eventHub.dispatch(UnfollowEvent(accountId))
@ -276,22 +283,22 @@ class AccountViewModel @Inject constructor(
},
{ t ->
Log.w(TAG, "failed loading relationship", t)
relationshipData.postValue(Error(relation, cause = t))
_relationshipData.value = Error(relation, cause = t)
}
)
}
fun noteChanged(newNote: String) {
noteSaved.postValue(false)
_noteSaved.value = false
noteUpdateJob?.cancel()
noteUpdateJob = viewModelScope.launch {
delay(1500)
mastodonApi.updateAccountNote(accountId, newNote)
.fold(
{
noteSaved.postValue(true)
_noteSaved.value = true
delay(4000)
noteSaved.postValue(false)
_noteSaved.value = false
},
{ t ->
Log.w(TAG, "Error updating note", t)

View File

@ -26,6 +26,7 @@ import android.view.View
import android.widget.PopupWindow
import androidx.activity.viewModels
import androidx.core.view.MenuProvider
import androidx.lifecycle.lifecycleScope
import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
@ -53,6 +54,7 @@ import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
import com.mikepenz.iconics.utils.colorInt
import com.mikepenz.iconics.utils.sizeDp
import javax.inject.Inject
import kotlinx.coroutines.launch
class AnnouncementsActivity :
BottomSheetActivity(),
@ -111,41 +113,46 @@ class AnnouncementsActivity :
binding.announcementsList.adapter = adapter
viewModel.announcements.observe(this) {
when (it) {
is Success -> {
binding.progressBar.hide()
binding.swipeRefreshLayout.isRefreshing = false
if (it.data.isNullOrEmpty()) {
binding.errorMessageView.setup(
R.drawable.elephant_friend_empty,
R.string.no_announcements
)
binding.errorMessageView.show()
} else {
lifecycleScope.launch {
viewModel.announcements.collect {
if (it == null) return@collect
when (it) {
is Success -> {
binding.progressBar.hide()
binding.swipeRefreshLayout.isRefreshing = false
if (it.data.isNullOrEmpty()) {
binding.errorMessageView.setup(
R.drawable.elephant_friend_empty,
R.string.no_announcements
)
binding.errorMessageView.show()
} else {
binding.errorMessageView.hide()
}
adapter.updateList(it.data ?: listOf())
}
is Loading -> {
binding.errorMessageView.hide()
}
adapter.updateList(it.data ?: listOf())
}
is Loading -> {
binding.errorMessageView.hide()
}
is Error -> {
binding.progressBar.hide()
binding.swipeRefreshLayout.isRefreshing = false
binding.errorMessageView.setup(
R.drawable.errorphant_error,
R.string.error_generic
) {
refreshAnnouncements()
is Error -> {
binding.progressBar.hide()
binding.swipeRefreshLayout.isRefreshing = false
binding.errorMessageView.setup(
R.drawable.errorphant_error,
R.string.error_generic
) {
refreshAnnouncements()
}
binding.errorMessageView.show()
}
binding.errorMessageView.show()
}
}
}
viewModel.emojis.observe(this) {
picker.adapter = EmojiAdapter(it, this, animateEmojis)
lifecycleScope.launch {
viewModel.emoji.collect {
picker.adapter = EmojiAdapter(it, this@AnnouncementsActivity, animateEmojis)
}
}
viewModel.load()

View File

@ -16,8 +16,6 @@
package com.keylesspalace.tusky.components.announcements
import android.util.Log
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import at.connyduck.calladapter.networkresult.fold
@ -32,6 +30,9 @@ import com.keylesspalace.tusky.util.Loading
import com.keylesspalace.tusky.util.Resource
import com.keylesspalace.tusky.util.Success
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
class AnnouncementsViewModel @Inject constructor(
@ -40,25 +41,25 @@ class AnnouncementsViewModel @Inject constructor(
private val eventHub: EventHub
) : ViewModel() {
private val announcementsMutable = MutableLiveData<Resource<List<Announcement>>>()
val announcements: LiveData<Resource<List<Announcement>>> = announcementsMutable
private val _announcements = MutableStateFlow(null as Resource<List<Announcement>>?)
val announcements: StateFlow<Resource<List<Announcement>>?> = _announcements.asStateFlow()
private val emojisMutable = MutableLiveData<List<Emoji>>()
val emojis: LiveData<List<Emoji>> = emojisMutable
private val _emoji = MutableStateFlow(emptyList<Emoji>())
val emoji: StateFlow<List<Emoji>> = _emoji.asStateFlow()
init {
viewModelScope.launch {
emojisMutable.postValue(instanceInfoRepo.getEmojis())
_emoji.value = instanceInfoRepo.getEmojis()
}
}
fun load() {
viewModelScope.launch {
announcementsMutable.postValue(Loading())
_announcements.value = Loading()
mastodonApi.listAnnouncements()
.fold(
{
announcementsMutable.postValue(Success(it))
_announcements.value = Success(it)
it.filter { announcement -> !announcement.read }
.forEach { announcement ->
mastodonApi.dismissAnnouncement(announcement.id)
@ -79,7 +80,7 @@ class AnnouncementsViewModel @Inject constructor(
}
},
{
announcementsMutable.postValue(Error(cause = it))
_announcements.value = Error(cause = it)
}
)
}
@ -90,9 +91,9 @@ class AnnouncementsViewModel @Inject constructor(
mastodonApi.addAnnouncementReaction(announcementId, name)
.fold(
{
announcementsMutable.postValue(
_announcements.value =
Success(
announcements.value!!.data!!.map { announcement ->
announcements.value?.data?.map { announcement ->
if (announcement.id == announcementId) {
announcement.copy(
reactions = if (announcement.reactions.find { reaction -> reaction.name == name } != null) {
@ -109,7 +110,7 @@ class AnnouncementsViewModel @Inject constructor(
} else {
listOf(
*announcement.reactions.toTypedArray(),
emojis.value!!.find { emoji -> emoji.shortcode == name }!!.run {
emoji.value.find { emoji -> emoji.shortcode == name }!!.run {
Announcement.Reaction(
name,
1,
@ -126,7 +127,6 @@ class AnnouncementsViewModel @Inject constructor(
}
}
)
)
},
{
Log.w(TAG, "Failed to add reaction to the announcement.", it)
@ -140,7 +140,7 @@ class AnnouncementsViewModel @Inject constructor(
mastodonApi.removeAnnouncementReaction(announcementId, name)
.fold(
{
announcementsMutable.postValue(
_announcements.value =
Success(
announcements.value!!.data!!.map { announcement ->
if (announcement.id == announcementId) {
@ -165,7 +165,6 @@ class AnnouncementsViewModel @Inject constructor(
}
}
)
)
},
{
Log.w(TAG, "Failed to remove reaction from the announcement.", it)

View File

@ -19,6 +19,7 @@ import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.activity.viewModels
import androidx.lifecycle.lifecycleScope
import com.keylesspalace.tusky.BottomSheetActivity
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.components.report.adapter.ReportPagerAdapter
@ -28,6 +29,7 @@ import com.keylesspalace.tusky.util.viewBinding
import dagger.android.DispatchingAndroidInjector
import dagger.android.HasAndroidInjector
import javax.inject.Inject
import kotlinx.coroutines.launch
class ReportActivity : BottomSheetActivity(), HasAndroidInjector {
@ -82,8 +84,9 @@ class ReportActivity : BottomSheetActivity(), HasAndroidInjector {
}
private fun subscribeObservables() {
viewModel.navigation.observe(this) { screen ->
if (screen != null) {
lifecycleScope.launch {
viewModel.navigation.collect { screen ->
if (screen == null) return@collect
viewModel.navigated()
when (screen) {
Screen.Statuses -> showStatusesPage()
@ -95,10 +98,12 @@ class ReportActivity : BottomSheetActivity(), HasAndroidInjector {
}
}
viewModel.checkUrl.observe(this) {
if (!it.isNullOrBlank()) {
viewModel.urlChecked()
viewUrl(it)
lifecycleScope.launch {
viewModel.checkUrl.collect {
if (!it.isNullOrBlank()) {
viewModel.urlChecked()
viewUrl(it)
}
}
}
}

View File

@ -15,8 +15,6 @@
package com.keylesspalace.tusky.components.report
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.paging.Pager
@ -40,6 +38,9 @@ import com.keylesspalace.tusky.util.toViewData
import javax.inject.Inject
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
@ -49,20 +50,20 @@ class ReportViewModel @Inject constructor(
private val eventHub: EventHub
) : ViewModel() {
private val navigationMutable = MutableLiveData<Screen?>()
val navigation: LiveData<Screen?> = navigationMutable
private val navigationMutable = MutableStateFlow(null as Screen?)
val navigation: StateFlow<Screen?> = navigationMutable.asStateFlow()
private val muteStateMutable = MutableLiveData<Resource<Boolean>>()
val muteState: LiveData<Resource<Boolean>> = muteStateMutable
private val muteStateMutable = MutableStateFlow(null as Resource<Boolean>?)
val muteState: StateFlow<Resource<Boolean>?> = muteStateMutable.asStateFlow()
private val blockStateMutable = MutableLiveData<Resource<Boolean>>()
val blockState: LiveData<Resource<Boolean>> = blockStateMutable
private val blockStateMutable = MutableStateFlow(null as Resource<Boolean>?)
val blockState: StateFlow<Resource<Boolean>?> = blockStateMutable.asStateFlow()
private val reportingStateMutable = MutableLiveData<Resource<Boolean>>()
var reportingState: LiveData<Resource<Boolean>> = reportingStateMutable
private val reportingStateMutable = MutableStateFlow(null as Resource<Boolean>?)
var reportingState: StateFlow<Resource<Boolean>?> = reportingStateMutable.asStateFlow()
private val checkUrlMutable = MutableLiveData<String?>()
val checkUrl: LiveData<String?> = checkUrlMutable
private val checkUrlMutable = MutableStateFlow(null as String?)
val checkUrl: StateFlow<String?> = checkUrlMutable.asStateFlow()
private val accountIdFlow = MutableSharedFlow<String>(
replay = 1,

View File

@ -19,6 +19,7 @@ import android.os.Bundle
import android.view.View
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.lifecycleScope
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.components.report.ReportViewModel
import com.keylesspalace.tusky.components.report.Screen
@ -30,6 +31,7 @@ import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.viewBinding
import javax.inject.Inject
import kotlinx.coroutines.launch
class ReportDoneFragment : Fragment(R.layout.fragment_report_done), Injectable {
@ -47,37 +49,43 @@ class ReportDoneFragment : Fragment(R.layout.fragment_report_done), Injectable {
}
private fun subscribeObservables() {
viewModel.muteState.observe(viewLifecycleOwner) {
if (it !is Loading) {
binding.buttonMute.show()
binding.progressMute.show()
} else {
binding.buttonMute.hide()
binding.progressMute.hide()
}
binding.buttonMute.setText(
when (it.data) {
true -> R.string.action_unmute
else -> R.string.action_mute
viewLifecycleOwner.lifecycleScope.launch {
viewModel.muteState.collect {
if (it == null) return@collect
if (it !is Loading) {
binding.buttonMute.show()
binding.progressMute.show()
} else {
binding.buttonMute.hide()
binding.progressMute.hide()
}
)
binding.buttonMute.setText(
when (it.data) {
true -> R.string.action_unmute
else -> R.string.action_mute
}
)
}
}
viewModel.blockState.observe(viewLifecycleOwner) {
if (it !is Loading) {
binding.buttonBlock.show()
binding.progressBlock.show()
} else {
binding.buttonBlock.hide()
binding.progressBlock.hide()
}
binding.buttonBlock.setText(
when (it.data) {
true -> R.string.action_unblock
else -> R.string.action_block
viewLifecycleOwner.lifecycleScope.launch {
viewModel.blockState.collect {
if (it == null) return@collect
if (it !is Loading) {
binding.buttonBlock.show()
binding.progressBlock.show()
} else {
binding.buttonBlock.hide()
binding.progressBlock.hide()
}
)
binding.buttonBlock.setText(
when (it.data) {
true -> R.string.action_unblock
else -> R.string.action_block
}
)
}
}
}

View File

@ -20,6 +20,7 @@ import android.view.View
import androidx.core.widget.doAfterTextChanged
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.lifecycleScope
import com.google.android.material.snackbar.Snackbar
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.components.report.ReportViewModel
@ -35,6 +36,7 @@ import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.viewBinding
import java.io.IOException
import javax.inject.Inject
import kotlinx.coroutines.launch
class ReportNoteFragment : Fragment(R.layout.fragment_report_note), Injectable {
@ -79,11 +81,14 @@ class ReportNoteFragment : Fragment(R.layout.fragment_report_note), Injectable {
}
private fun subscribeObservables() {
viewModel.reportingState.observe(viewLifecycleOwner) {
when (it) {
is Success -> viewModel.navigateTo(Screen.Done)
is Loading -> showLoading()
is Error -> showError(it.cause)
viewLifecycleOwner.lifecycleScope.launch {
viewModel.reportingState.collect {
if (it == null) return@collect
when (it) {
is Success -> viewModel.navigateTo(Screen.Done)
is Loading -> showLoading()
is Error -> showError(it.cause)
}
}
}
}

View File

@ -15,12 +15,12 @@
package com.keylesspalace.tusky.db
import androidx.lifecycle.LiveData
import androidx.paging.PagingSource
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import kotlinx.coroutines.flow.Flow
@Dao
interface DraftDao {
@ -32,7 +32,7 @@ interface DraftDao {
fun draftsPagingSource(accountId: Long): PagingSource<Int, DraftEntity>
@Query("SELECT COUNT(*) FROM DraftEntity WHERE accountId = :accountId AND failedToSendNew = 1")
fun draftsNeedUserAlert(accountId: Long): LiveData<Int>
fun draftsNeedUserAlert(accountId: Long): Flow<Int>
@Query(
"UPDATE DraftEntity SET failedToSendNew = 0 WHERE accountId = :accountId AND failedToSendNew = 1"

View File

@ -51,36 +51,37 @@ class DraftsAlert @Inject constructor(db: AppDatabase) {
// Assume a single MainActivity, AccountActivity or DraftsActivity never sees more then one user id in its lifetime.
val activeAccountId = activeAccount.id
// This LiveData will be automatically disposed when the activity is destroyed.
val draftsNeedUserAlert = draftDao.draftsNeedUserAlert(activeAccountId)
// observe ensures that this gets called at the most appropriate moment wrt the context lifecycle—
// at init, at next onResume, or immediately if the context is resumed already.
if (showAlert) {
draftsNeedUserAlert.observe(context) { count ->
Log.d(TAG, "User id $activeAccountId changed: Notification-worthy draft count $count")
if (count > 0) {
AlertDialog.Builder(context)
.setTitle(R.string.action_post_failed)
.setMessage(
context.resources.getQuantityString(R.plurals.action_post_failed_detail, count)
)
.setPositiveButton(R.string.action_post_failed_show_drafts) { _: DialogInterface?, _: Int ->
clearDraftsAlert(coroutineScope, activeAccountId) // User looked at drafts
coroutineScope.launch {
if (showAlert) {
draftsNeedUserAlert.collect { count ->
Log.d(TAG, "User id $activeAccountId changed: Notification-worthy draft count $count")
if (count > 0) {
AlertDialog.Builder(context)
.setTitle(R.string.action_post_failed)
.setMessage(
context.resources.getQuantityString(R.plurals.action_post_failed_detail, count)
)
.setPositiveButton(R.string.action_post_failed_show_drafts) { _: DialogInterface?, _: Int ->
clearDraftsAlert(coroutineScope, activeAccountId) // User looked at drafts
val intent = DraftsActivity.newIntent(context)
context.startActivity(intent)
}
.setNegativeButton(R.string.action_post_failed_do_nothing) { _: DialogInterface?, _: Int ->
clearDraftsAlert(coroutineScope, activeAccountId) // User doesn't care
}
.show()
val intent = DraftsActivity.newIntent(context)
context.startActivity(intent)
}
.setNegativeButton(R.string.action_post_failed_do_nothing) { _: DialogInterface?, _: Int ->
clearDraftsAlert(coroutineScope, activeAccountId) // User doesn't care
}
.show()
}
}
} else {
draftsNeedUserAlert.collect {
Log.d(TAG, "User id $activeAccountId: Clean out notification-worthy drafts")
clearDraftsAlert(coroutineScope, activeAccountId)
}
}
} else {
draftsNeedUserAlert.observe(context) {
Log.d(TAG, "User id $activeAccountId: Clean out notification-worthy drafts")
clearDraftsAlert(coroutineScope, activeAccountId)
}
}
} ?: run {

View File

@ -18,7 +18,6 @@ package com.keylesspalace.tusky.viewmodel
import android.app.Application
import android.net.Uri
import androidx.core.net.toUri
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import at.connyduck.calladapter.networkresult.fold
@ -40,6 +39,7 @@ import javax.inject.Inject
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.shareIn
@ -66,10 +66,17 @@ class EditProfileViewModel @Inject constructor(
private val instanceInfoRepo: InstanceInfoRepository
) : ViewModel() {
val profileData = MutableLiveData<Resource<Account>>()
val avatarData = MutableLiveData<Uri>()
val headerData = MutableLiveData<Uri>()
val saveData = MutableLiveData<Resource<Nothing>>()
private val _profileData = MutableStateFlow(null as Resource<Account>?)
val profileData: StateFlow<Resource<Account>?> = _profileData.asStateFlow()
private val _avatarData = MutableStateFlow(null as Uri?)
val avatarData: StateFlow<Uri?> = _avatarData.asStateFlow()
private val _headerData = MutableStateFlow(null as Uri?)
val headerData: StateFlow<Uri?> = _headerData.asStateFlow()
private val _saveData = MutableStateFlow(null as Resource<Nothing>?)
val saveData: StateFlow<Resource<Nothing>?> = _saveData.asStateFlow()
val instanceData: Flow<InstanceInfo> = instanceInfoRepo::getUpdatedInstanceInfoOrFallback.asFlow()
.shareIn(viewModelScope, SharingStarted.Eagerly, replay = 1)
@ -80,16 +87,16 @@ class EditProfileViewModel @Inject constructor(
private var apiProfileAccount: Account? = null
fun obtainProfile() = viewModelScope.launch {
if (profileData.value == null || profileData.value is Error) {
profileData.postValue(Loading())
if (_profileData.value == null || _profileData.value is Error) {
_profileData.value = Loading()
mastodonApi.accountVerifyCredentials().fold(
{ profile ->
apiProfileAccount = profile
profileData.postValue(Success(profile))
_profileData.value = Success(profile)
},
{
profileData.postValue(Error())
_profileData.value = Error()
}
)
}
@ -100,11 +107,11 @@ class EditProfileViewModel @Inject constructor(
fun getHeaderUri() = getCacheFileForName(HEADER_FILE_NAME).toUri()
fun newAvatarPicked() {
avatarData.value = getAvatarUri()
_avatarData.value = getAvatarUri()
}
fun newHeaderPicked() {
headerData.value = getHeaderUri()
_headerData.value = getHeaderUri()
}
internal fun dataChanged(newProfileData: ProfileDataInUi) {
@ -112,16 +119,16 @@ class EditProfileViewModel @Inject constructor(
}
internal fun save(newProfileData: ProfileDataInUi) {
if (saveData.value is Loading || profileData.value !is Success) {
if (_saveData.value is Loading || _profileData.value !is Success) {
return
}
saveData.value = Loading()
_saveData.value = Loading()
val diff = getProfileDiff(apiProfileAccount, newProfileData)
if (!diff.hasChanges()) {
// if nothing has changed, there is no need to make an api call
saveData.value = Success()
_saveData.value = Success()
return
}
@ -160,11 +167,11 @@ class EditProfileViewModel @Inject constructor(
diff.field4?.second?.toRequestBody(MultipartBody.FORM)
).fold(
{ newAccountData ->
saveData.postValue(Success())
_saveData.value = Success()
eventHub.dispatch(ProfileEditedEvent(newAccountData))
},
{ throwable ->
saveData.postValue(Error(errorMessage = throwable.getServerErrorMessage()))
_saveData.value = Error(errorMessage = throwable.getServerErrorMessage())
}
)
}
@ -172,18 +179,18 @@ class EditProfileViewModel @Inject constructor(
// cache activity state for rotation change
internal fun updateProfile(newProfileData: ProfileDataInUi) {
if (profileData.value is Success) {
val newProfileSource = profileData.value?.data?.source?.copy(
if (_profileData.value is Success) {
val newProfileSource = _profileData.value?.data?.source?.copy(
note = newProfileData.note,
fields = newProfileData.fields
)
val newProfile = profileData.value?.data?.copy(
val newProfile = _profileData.value?.data?.copy(
displayName = newProfileData.displayName,
locked = newProfileData.locked,
source = newProfileSource
)
profileData.value = Success(newProfile)
_profileData.value = Success(newProfile)
}
}
@ -209,13 +216,13 @@ class EditProfileViewModel @Inject constructor(
newProfileData.locked
}
val avatarFile = if (avatarData.value != null) {
val avatarFile = if (_avatarData.value != null) {
getCacheFileForName(AVATAR_FILE_NAME)
} else {
null
}
val headerFile = if (headerData.value != null) {
val headerFile = if (_headerData.value != null) {
getCacheFileForName(HEADER_FILE_NAME)
} else {
null

View File

@ -75,7 +75,6 @@ androidx-emoji2-view-helper = { module = "androidx.emoji2:emoji2-views-helper",
androidx-exifinterface = { module = "androidx.exifinterface:exifinterface", version.ref = "androidx-exifinterface" }
androidx-fragment-ktx = { module = "androidx.fragment:fragment-ktx", version.ref = "androidx-fragment" }
androidx-lifecycle-common-java8 = { module = "androidx.lifecycle:lifecycle-common-java8", version.ref = "androidx-lifecycle" }
androidx-lifecycle-livedata-ktx = { module = "androidx.lifecycle:lifecycle-livedata-ktx", version.ref = "androidx-lifecycle" }
androidx-lifecycle-reactivestreams-ktx = { module = "androidx.lifecycle:lifecycle-reactivestreams-ktx", version.ref = "androidx-lifecycle" }
androidx-lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "androidx-lifecycle" }
androidx-media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "androidx-media3" }
@ -137,7 +136,7 @@ xmlwriter = { module = "org.pageseeder.xmlwriter:pso-xmlwriter", version.ref = "
androidx = ["androidx-core-ktx", "androidx-appcompat", "androidx-fragment-ktx", "androidx-browser", "androidx-swiperefreshlayout",
"androidx-recyclerview", "androidx-exifinterface", "androidx-cardview", "androidx-preference-ktx", "androidx-sharetarget",
"androidx-emoji2-core", "androidx-emoji2-views-core", "androidx-emoji2-view-helper", "androidx-lifecycle-viewmodel-ktx",
"androidx-lifecycle-livedata-ktx", "androidx-lifecycle-common-java8", "androidx-lifecycle-reactivestreams-ktx",
"androidx-lifecycle-common-java8", "androidx-lifecycle-reactivestreams-ktx",
"androidx-constraintlayout", "androidx-paging-runtime-ktx", "androidx-viewpager2", "androidx-work-runtime-ktx",
"androidx-core-splashscreen", "androidx-activity", "androidx-media3-exoplayer", "androidx-media3-datasource-okhttp",
"androidx-media3-ui"]