From 461ec8d7226764a58a7882f286efb687c11ff7bc Mon Sep 17 00:00:00 2001 From: Martin Marconcini Date: Sat, 19 Aug 2023 17:36:00 +0200 Subject: [PATCH] Prompt user before leaving edit profile when any field has been modified. --- .../tusky/EditProfileActivity.kt | 48 ++++-- .../tusky/viewmodel/EditProfileViewModel.kt | 138 +++++++++++------- app/src/main/res/values/strings.xml | 2 + 3 files changed, 124 insertions(+), 64 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/EditProfileActivity.kt b/app/src/main/java/com/keylesspalace/tusky/EditProfileActivity.kt index 190421e69..b734d92a8 100644 --- a/app/src/main/java/com/keylesspalace/tusky/EditProfileActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/EditProfileActivity.kt @@ -25,7 +25,9 @@ import android.view.Menu import android.view.MenuItem import android.view.View import android.widget.ImageView +import androidx.activity.OnBackPressedCallback import androidx.activity.viewModels +import androidx.appcompat.app.AlertDialog import androidx.core.view.isVisible import androidx.lifecycle.LiveData 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.Loading import com.keylesspalace.tusky.util.Success +import com.keylesspalace.tusky.util.await import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.viewmodel.EditProfileViewModel +import com.keylesspalace.tusky.viewmodel.ProfileData import com.mikepenz.iconics.IconicsDrawable import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial 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() { super.onStop() if (!isFinishing) { - viewModel.updateProfile( - binding.displayNameEditText.text.toString(), - binding.noteEditText.text.toString(), - binding.lockedCheckBox.isChecked, - accountFieldEditAdapter.getFieldData() - ) + viewModel.updateProfile(gatherProfileData()) } } + private fun gatherProfileData() = ProfileData( + displayName = binding.displayNameEditText.text.toString(), + note = binding.noteEditText.text.toString(), + locked = binding.lockedCheckBox.isChecked, + fields = accountFieldEditAdapter.getFieldData(), + ) + private fun observeImage( liveData: LiveData, imageView: ImageView, @@ -287,14 +308,7 @@ class EditProfileActivity : BaseActivity(), Injectable { return super.onOptionsItemSelected(item) } - private fun save() { - viewModel.save( - binding.displayNameEditText.text.toString(), - binding.noteEditText.text.toString(), - binding.lockedCheckBox.isChecked, - accountFieldEditAdapter.getFieldData() - ) - } + private fun save() = viewModel.save(gatherProfileData()) private fun onSaveFailure(msg: String?) { 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) 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) } diff --git a/app/src/main/java/com/keylesspalace/tusky/viewmodel/EditProfileViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/viewmodel/EditProfileViewModel.kt index 21a8a01d7..46b16d5ed 100644 --- a/app/src/main/java/com/keylesspalace/tusky/viewmodel/EditProfileViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/viewmodel/EditProfileViewModel.kt @@ -51,6 +51,17 @@ import javax.inject.Inject private const val HEADER_FILE_NAME = "header.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, +) + class EditProfileViewModel @Inject constructor( private val mastodonApi: MastodonApi, private val eventHub: EventHub, @@ -96,29 +107,73 @@ class EditProfileViewModel @Inject constructor( headerData.value = getHeaderUri() } - fun save(newDisplayName: String, newNote: String, newLocked: Boolean, newFields: List) { + internal fun save(newProfileData: ProfileData) { if (saveData.value is Loading || profileData.value !is Success) { return } saveData.value = Loading() - val displayName = if (oldProfileData?.displayName == newDisplayName) { - null - } else { - newDisplayName.toRequestBody(MultipartBody.FORM) + val encoded = encodeChangedProfileFields(newProfileData) + if (encoded.allFieldsAreNull()) { + // if nothing has changed, there is no need to make a network request + 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 } 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 } 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) { @@ -136,48 +191,15 @@ class EditProfileViewModel @Inject constructor( } // when one field changed, all have to be sent or they unchanged ones would get overridden - val fieldsUnchanged = oldProfileData?.source?.fields == newFields - val field1 = calculateFieldToUpdate(newFields.getOrNull(0), fieldsUnchanged) - val field2 = calculateFieldToUpdate(newFields.getOrNull(1), fieldsUnchanged) - val field3 = calculateFieldToUpdate(newFields.getOrNull(2), fieldsUnchanged) - val field4 = calculateFieldToUpdate(newFields.getOrNull(3), fieldsUnchanged) + val fieldsUnchanged = oldProfileData?.source?.fields == newProfileData.fields + val field1 = calculateFieldToUpdate(newProfileData.fields.getOrNull(0), fieldsUnchanged) + val field2 = calculateFieldToUpdate(newProfileData.fields.getOrNull(1), fieldsUnchanged) + val field3 = calculateFieldToUpdate(newProfileData.fields.getOrNull(2), fieldsUnchanged) + val field4 = calculateFieldToUpdate(newProfileData.fields.getOrNull(3), fieldsUnchanged) - if (displayName == null && note == null && locked == null && avatar == null && header == null && - field1 == null && field2 == null && field3 == null && field4 == null - ) { - /** 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) { - 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)) - } + return EncodedProfileData( + displayName, note, locked, field1, field2, field3, field4, header, avatar + ) } private fun calculateFieldToUpdate(newField: StringField?, fieldsUnchanged: Boolean): Pair? { @@ -193,4 +215,20 @@ class EditProfileViewModel @Inject constructor( private fun getCacheFileForName(filename: String): File { return File(application.cacheDir, filename) } + + private data class EncodedProfileData( + val displayName: RequestBody?, + val note: RequestBody?, + val locked: RequestBody?, + val field1: Pair?, + val field2: Pair?, + val field3: Pair?, + val field4: Pair?, + 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 + } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index fdf776200..b43965fbc 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -820,4 +820,6 @@ Playback failed: %s Delete filter \'%1$s\'?" Delete + Unsaved Changes + Do you want to save your profile changes?