Prompt user before leaving edit profile when any field has been modified.

This commit is contained in:
Martin Marconcini 2023-08-19 17:36:00 +02:00
parent 85f0b1f320
commit 461ec8d722
No known key found for this signature in database
3 changed files with 124 additions and 64 deletions

View file

@ -25,7 +25,9 @@ import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
import android.view.View import android.view.View
import android.widget.ImageView import android.widget.ImageView
import androidx.activity.OnBackPressedCallback
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.appcompat.app.AlertDialog
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
@ -46,9 +48,11 @@ import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.util.Error import com.keylesspalace.tusky.util.Error
import com.keylesspalace.tusky.util.Loading import com.keylesspalace.tusky.util.Loading
import com.keylesspalace.tusky.util.Success import com.keylesspalace.tusky.util.Success
import com.keylesspalace.tusky.util.await
import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.viewmodel.EditProfileViewModel import com.keylesspalace.tusky.viewmodel.EditProfileViewModel
import com.keylesspalace.tusky.viewmodel.ProfileData
import com.mikepenz.iconics.IconicsDrawable import com.mikepenz.iconics.IconicsDrawable
import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
import com.mikepenz.iconics.utils.colorInt import com.mikepenz.iconics.utils.colorInt
@ -200,20 +204,37 @@ class EditProfileActivity : BaseActivity(), Injectable {
} }
} }
} }
val onBackCallback = object : OnBackPressedCallback(enabled = true) {
override fun handleOnBackPressed() {
if (!viewModel.hasUnsavedChanges(gatherProfileData())) finish()
lifecycleScope.launch {
when(showConfirmationDialog()) {
AlertDialog.BUTTON_POSITIVE -> save()
else -> finish()
}
}
}
}
onBackPressedDispatcher.addCallback(this, onBackCallback)
} }
override fun onStop() { override fun onStop() {
super.onStop() super.onStop()
if (!isFinishing) { if (!isFinishing) {
viewModel.updateProfile( viewModel.updateProfile(gatherProfileData())
binding.displayNameEditText.text.toString(),
binding.noteEditText.text.toString(),
binding.lockedCheckBox.isChecked,
accountFieldEditAdapter.getFieldData()
)
} }
} }
private fun gatherProfileData() = ProfileData(
displayName = binding.displayNameEditText.text.toString(),
note = binding.noteEditText.text.toString(),
locked = binding.lockedCheckBox.isChecked,
fields = accountFieldEditAdapter.getFieldData(),
)
private fun observeImage( private fun observeImage(
liveData: LiveData<Uri>, liveData: LiveData<Uri>,
imageView: ImageView, imageView: ImageView,
@ -287,14 +308,7 @@ class EditProfileActivity : BaseActivity(), Injectable {
return super.onOptionsItemSelected(item) return super.onOptionsItemSelected(item)
} }
private fun save() { private fun save() = viewModel.save(gatherProfileData())
viewModel.save(
binding.displayNameEditText.text.toString(),
binding.noteEditText.text.toString(),
binding.lockedCheckBox.isChecked,
accountFieldEditAdapter.getFieldData()
)
}
private fun onSaveFailure(msg: String?) { private fun onSaveFailure(msg: String?) {
val errorMsg = msg ?: getString(R.string.error_media_upload_sending) val errorMsg = msg ?: getString(R.string.error_media_upload_sending)
@ -306,4 +320,10 @@ class EditProfileActivity : BaseActivity(), Injectable {
Log.w("EditProfileActivity", "failed to pick media", throwable) Log.w("EditProfileActivity", "failed to pick media", throwable)
Snackbar.make(binding.avatarButton, R.string.error_media_upload_sending, Snackbar.LENGTH_LONG).show() Snackbar.make(binding.avatarButton, R.string.error_media_upload_sending, Snackbar.LENGTH_LONG).show()
} }
private suspend fun showConfirmationDialog() = AlertDialog.Builder(this)
.setTitle(getString(R.string.title_edit_profile_save_changes_prompt))
.setMessage(getString(R.string.message_edit_profile_save_changes_prompt))
.create()
.await(R.string.action_save, R.string.action_discard)
} }

View file

@ -51,6 +51,17 @@ import javax.inject.Inject
private const val HEADER_FILE_NAME = "header.png" private const val HEADER_FILE_NAME = "header.png"
private const val AVATAR_FILE_NAME = "avatar.png" private const val AVATAR_FILE_NAME = "avatar.png"
/**
* Conveniently groups Profile Data users can modify in the UI.
*/
internal data class ProfileData(
val displayName: String,
val note: String,
val locked: Boolean,
val fields: List<StringField>,
)
class EditProfileViewModel @Inject constructor( class EditProfileViewModel @Inject constructor(
private val mastodonApi: MastodonApi, private val mastodonApi: MastodonApi,
private val eventHub: EventHub, private val eventHub: EventHub,
@ -96,29 +107,73 @@ class EditProfileViewModel @Inject constructor(
headerData.value = getHeaderUri() headerData.value = getHeaderUri()
} }
fun save(newDisplayName: String, newNote: String, newLocked: Boolean, newFields: List<StringField>) { internal fun save(newProfileData: ProfileData) {
if (saveData.value is Loading || profileData.value !is Success) { if (saveData.value is Loading || profileData.value !is Success) {
return return
} }
saveData.value = Loading() saveData.value = Loading()
val displayName = if (oldProfileData?.displayName == newDisplayName) { val encoded = encodeChangedProfileFields(newProfileData)
null if (encoded.allFieldsAreNull()) {
} else { // if nothing has changed, there is no need to make a network request
newDisplayName.toRequestBody(MultipartBody.FORM) saveData.postValue(Success())
return
} }
val note = if (oldProfileData?.source?.note == newNote) { viewModelScope.launch {
mastodonApi.accountUpdateCredentials(
encoded.displayName, encoded.note, encoded.locked, encoded.avatar, encoded.header,
encoded.field1?.first, encoded.field1?.second, encoded.field2?.first, encoded.field2?.second, encoded.field3?.first, encoded.field3?.second, encoded.field4?.first, encoded.field4?.second
).fold(
{ newProfileData ->
saveData.postValue(Success())
eventHub.dispatch(ProfileEditedEvent(newProfileData))
},
{ throwable ->
saveData.postValue(Error(errorMessage = throwable.getServerErrorMessage()))
}
)
}
}
// cache activity state for rotation change
internal fun updateProfile(newProfileData: ProfileData) {
if (profileData.value is Success) {
val newProfileSource = profileData.value?.data?.source?.copy(note = newProfileData.note, fields = newProfileData.fields)
val newProfile = profileData.value?.data?.copy(
displayName = newProfileData.displayName,
locked = newProfileData.locked,
source = newProfileSource
)
profileData.postValue(Success(newProfile))
}
}
internal fun hasUnsavedChanges(newProfileData: ProfileData) : Boolean {
val encoded = encodeChangedProfileFields(newProfileData)
// If all fields are null, there are no changes.
return !encoded.allFieldsAreNull()
}
private fun encodeChangedProfileFields(newProfileData: ProfileData): EncodedProfileData {
val displayName = if (oldProfileData?.displayName == newProfileData.displayName) {
null null
} else { } else {
newNote.toRequestBody(MultipartBody.FORM) newProfileData.displayName.toRequestBody(MultipartBody.FORM)
} }
val locked = if (oldProfileData?.locked == newLocked) { val note = if (oldProfileData?.source?.note == newProfileData.note) {
null null
} else { } else {
newLocked.toString().toRequestBody(MultipartBody.FORM) newProfileData.note.toRequestBody(MultipartBody.FORM)
}
val locked = if (oldProfileData?.locked == newProfileData.locked) {
null
} else {
newProfileData.locked.toString().toRequestBody(MultipartBody.FORM)
} }
val avatar = if (avatarData.value != null) { val avatar = if (avatarData.value != null) {
@ -136,48 +191,15 @@ class EditProfileViewModel @Inject constructor(
} }
// when one field changed, all have to be sent or they unchanged ones would get overridden // when one field changed, all have to be sent or they unchanged ones would get overridden
val fieldsUnchanged = oldProfileData?.source?.fields == newFields val fieldsUnchanged = oldProfileData?.source?.fields == newProfileData.fields
val field1 = calculateFieldToUpdate(newFields.getOrNull(0), fieldsUnchanged) val field1 = calculateFieldToUpdate(newProfileData.fields.getOrNull(0), fieldsUnchanged)
val field2 = calculateFieldToUpdate(newFields.getOrNull(1), fieldsUnchanged) val field2 = calculateFieldToUpdate(newProfileData.fields.getOrNull(1), fieldsUnchanged)
val field3 = calculateFieldToUpdate(newFields.getOrNull(2), fieldsUnchanged) val field3 = calculateFieldToUpdate(newProfileData.fields.getOrNull(2), fieldsUnchanged)
val field4 = calculateFieldToUpdate(newFields.getOrNull(3), fieldsUnchanged) val field4 = calculateFieldToUpdate(newProfileData.fields.getOrNull(3), fieldsUnchanged)
if (displayName == null && note == null && locked == null && avatar == null && header == null && return EncodedProfileData(
field1 == null && field2 == null && field3 == null && field4 == null displayName, note, locked, field1, field2, field3, field4, header, avatar
) { )
/** if nothing has changed, there is no need to make a network request */
saveData.postValue(Success())
return
}
viewModelScope.launch {
mastodonApi.accountUpdateCredentials(
displayName, note, locked, avatar, header,
field1?.first, field1?.second, field2?.first, field2?.second, field3?.first, field3?.second, field4?.first, field4?.second
).fold(
{ newProfileData ->
saveData.postValue(Success())
eventHub.dispatch(ProfileEditedEvent(newProfileData))
},
{ throwable ->
saveData.postValue(Error(errorMessage = throwable.getServerErrorMessage()))
}
)
}
}
// cache activity state for rotation change
fun updateProfile(newDisplayName: String, newNote: String, newLocked: Boolean, newFields: List<StringField>) {
if (profileData.value is Success) {
val newProfileSource = profileData.value?.data?.source?.copy(note = newNote, fields = newFields)
val newProfile = profileData.value?.data?.copy(
displayName = newDisplayName,
locked = newLocked,
source = newProfileSource
)
profileData.postValue(Success(newProfile))
}
} }
private fun calculateFieldToUpdate(newField: StringField?, fieldsUnchanged: Boolean): Pair<RequestBody, RequestBody>? { private fun calculateFieldToUpdate(newField: StringField?, fieldsUnchanged: Boolean): Pair<RequestBody, RequestBody>? {
@ -193,4 +215,20 @@ class EditProfileViewModel @Inject constructor(
private fun getCacheFileForName(filename: String): File { private fun getCacheFileForName(filename: String): File {
return File(application.cacheDir, filename) return File(application.cacheDir, filename)
} }
private data class EncodedProfileData(
val displayName: RequestBody?,
val note: RequestBody?,
val locked: RequestBody?,
val field1: Pair<RequestBody, RequestBody>?,
val field2: Pair<RequestBody, RequestBody>?,
val field3: Pair<RequestBody, RequestBody>?,
val field4: Pair<RequestBody, RequestBody>?,
val header: MultipartBody.Part?,
val avatar: MultipartBody.Part?,
) {
fun allFieldsAreNull() = displayName == null && note == null && locked == null
&& avatar == null && header == null && field1 == null && field2 == null
&& field3 == null && field4 == null
}
} }

View file

@ -820,4 +820,6 @@
<string name="error_media_playback">Playback failed: %s</string> <string name="error_media_playback">Playback failed: %s</string>
<string name="dialog_delete_filter_text">Delete filter \'%1$s\'?"</string> <string name="dialog_delete_filter_text">Delete filter \'%1$s\'?"</string>
<string name="dialog_delete_filter_positive_action">Delete</string> <string name="dialog_delete_filter_positive_action">Delete</string>
<string name="title_edit_profile_save_changes_prompt">Unsaved Changes</string>
<string name="message_edit_profile_save_changes_prompt">Do you want to save your profile changes?</string>
</resources> </resources>