add possibility to change profile fields, refactor (#751)

* refactor EditProfileActivity, add profile fields

* preserve transparency when cropping profile images

* dont validate profile fields on client side

* revert unintentional change in card_frame_dark.xml

* improve activity_edit_profile layout for tablets

* Revert "improve activity_edit_profile layout for tablets"

This reverts commit 20ff3d167c39b15566e017108b33fe58690a8482.

* improve activity_edit_profile layout for tablets

* fix bug in EditProfileActivity, add snackbar

* improve EditProfileActivity code

* use events instead of shared prefs to communicate profile update
This commit is contained in:
Konrad Pozniak 2018-08-15 20:47:09 +02:00 committed by GitHub
commit f022944e90
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 727 additions and 395 deletions

View file

@ -17,72 +17,59 @@ package com.keylesspalace.tusky
import android.Manifest
import android.app.Activity
import android.content.ContentResolver
import android.arch.lifecycle.LiveData
import android.arch.lifecycle.Observer
import android.arch.lifecycle.ViewModelProviders
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Color
import android.net.Uri
import android.os.AsyncTask
import android.os.Bundle
import android.support.design.widget.Snackbar
import android.support.v4.app.ActivityCompat
import android.support.v4.content.ContextCompat
import android.util.Log
import android.support.v4.widget.TextViewCompat
import android.support.v7.widget.LinearLayoutManager
import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.widget.ImageView
import com.keylesspalace.tusky.adapter.AccountFieldEditAdapter
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.IOUtils
import com.keylesspalace.tusky.util.MediaUtils
import com.keylesspalace.tusky.util.*
import com.keylesspalace.tusky.viewmodel.EditProfileViewModel
import com.mikepenz.google_material_typeface_library.GoogleMaterial
import com.mikepenz.iconics.IconicsDrawable
import com.squareup.picasso.Picasso
import com.theartofdev.edmodo.cropper.CropImage
import kotlinx.android.synthetic.main.activity_edit_profile.*
import kotlinx.android.synthetic.main.toolbar_basic.*
import okhttp3.MediaType
import okhttp3.MultipartBody
import okhttp3.RequestBody
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response
import java.io.*
import java.util.*
import javax.inject.Inject
private const val TAG = "EditProfileActivity"
private const val HEADER_FILE_NAME = "header.png"
private const val AVATAR_FILE_NAME = "avatar.png"
private const val KEY_OLD_DISPLAY_NAME = "OLD_DISPLAY_NAME"
private const val KEY_OLD_NOTE = "OLD_NOTE"
private const val KEY_OLD_LOCKED = "OLD_LOCKED"
private const val KEY_IS_SAVING = "IS_SAVING"
private const val KEY_CURRENTLY_PICKING = "CURRENTLY_PICKING"
private const val KEY_AVATAR_CHANGED = "AVATAR_CHANGED"
private const val KEY_HEADER_CHANGED = "HEADER_CHANGED"
private const val AVATAR_PICK_RESULT = 1
private const val HEADER_PICK_RESULT = 2
private const val PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE = 1
private const val AVATAR_SIZE = 400
private const val HEADER_WIDTH = 700
private const val HEADER_HEIGHT = 335
class EditProfileActivity : BaseActivity(), Injectable {
private var oldDisplayName: String? = null
private var oldNote: String? = null
private var oldLocked: Boolean = false
private var isSaving: Boolean = false
private var currentlyPicking: PickType = PickType.NOTHING
private var avatarChanged: Boolean = false
private var headerChanged: Boolean = false
companion object {
const val AVATAR_SIZE = 400
const val HEADER_WIDTH = 700
const val HEADER_HEIGHT = 335
private const val AVATAR_PICK_RESULT = 1
private const val HEADER_PICK_RESULT = 2
private const val PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE = 1
private const val MAX_ACCOUNT_FIELDS = 4
}
@Inject
lateinit var mastodonApi: MastodonApi
lateinit var viewModelFactory: ViewModelFactory
private lateinit var viewModel: EditProfileViewModel
private var currentlyPicking: PickType = PickType.NOTHING
private val accountFieldEditAdapter = AccountFieldEditAdapter()
private enum class PickType {
NOTHING,
@ -94,93 +81,127 @@ class EditProfileActivity : BaseActivity(), Injectable {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_edit_profile)
viewModel = ViewModelProviders.of(this, viewModelFactory)[EditProfileViewModel::class.java]
setSupportActionBar(toolbar)
supportActionBar?.run {
setTitle(R.string.title_edit_profile)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
supportActionBar?.setDisplayShowHomeEnabled(true)
}
savedInstanceState?.let {
oldDisplayName = it.getString(KEY_OLD_DISPLAY_NAME)
oldNote = it.getString(KEY_OLD_NOTE)
oldLocked = it.getBoolean(KEY_OLD_LOCKED)
isSaving = it.getBoolean(KEY_IS_SAVING)
currentlyPicking = it.getSerializable(KEY_CURRENTLY_PICKING) as PickType
avatarChanged = it.getBoolean(KEY_AVATAR_CHANGED)
headerChanged = it.getBoolean(KEY_HEADER_CHANGED)
if (avatarChanged) {
val avatar = BitmapFactory.decodeFile(getCacheFileForName(AVATAR_FILE_NAME).absolutePath)
avatarPreview.setImageBitmap(avatar)
}
if (headerChanged) {
val header = BitmapFactory.decodeFile(getCacheFileForName(HEADER_FILE_NAME).absolutePath)
headerPreview.setImageBitmap(header)
}
setDisplayHomeAsUpEnabled(true)
setDisplayShowHomeEnabled(true)
}
avatarButton.setOnClickListener { onMediaPick(PickType.AVATAR) }
headerButton.setOnClickListener { onMediaPick(PickType.HEADER) }
avatarPreview.setOnClickListener {
avatarPreview.setImageBitmap(null)
avatarPreview.visibility = View.INVISIBLE
}
headerPreview.setOnClickListener {
headerPreview.setImageBitmap(null)
headerPreview.visibility = View.INVISIBLE
}
fieldList.layoutManager = LinearLayoutManager(this)
fieldList.adapter = accountFieldEditAdapter
mastodonApi.accountVerifyCredentials().enqueue(object : Callback<Account> {
override fun onResponse(call: Call<Account>, response: Response<Account>) {
if (!response.isSuccessful) {
onAccountVerifyCredentialsFailed()
return
}
val me = response.body()
oldDisplayName = me!!.displayName
oldNote = me.source?.note
oldLocked = me.locked
val plusDrawable = IconicsDrawable(this, GoogleMaterial.Icon.gmd_add).sizeDp(12).color(Color.WHITE)
displayNameEditText.setText(oldDisplayName)
noteEditText.setText(oldNote)
lockedCheckBox.isChecked = oldLocked
TextViewCompat.setCompoundDrawablesRelativeWithIntrinsicBounds(addFieldButton, plusDrawable, null, null, null)
if (!avatarChanged) {
Picasso.with(avatarPreview.context)
.load(me.avatar)
.placeholder(R.drawable.avatar_default)
.into(avatarPreview)
}
if (!headerChanged) {
Picasso.with(headerPreview.context)
.load(me.header)
.into(headerPreview)
}
addFieldButton.setOnClickListener {
accountFieldEditAdapter.addField()
if(accountFieldEditAdapter.itemCount >= MAX_ACCOUNT_FIELDS) {
it.isEnabled = false
}
override fun onFailure(call: Call<Account>, t: Throwable) {
onAccountVerifyCredentialsFailed()
scrollView.post{
scrollView.smoothScrollTo(0, it.bottom)
}
}
viewModel.obtainProfile()
viewModel.profileData.observe(this, Observer<Resource<Account>> { profileRes ->
when (profileRes) {
is Success -> {
val me = profileRes.data
if (me != null) {
displayNameEditText.setText(me.displayName)
noteEditText.setText(me.source?.note)
lockedCheckBox.isChecked = me.locked
accountFieldEditAdapter.setFields(me.source?.fields ?: emptyList())
if(viewModel.avatarData.value == null) {
Picasso.with(this)
.load(me.avatar)
.placeholder(R.drawable.avatar_default)
.into(avatarPreview)
}
if(viewModel.headerData.value == null) {
Picasso.with(this)
.load(me.header)
.into(headerPreview)
}
}
}
is Error -> {
val snackbar = Snackbar.make(avatarButton, R.string.error_generic, Snackbar.LENGTH_LONG);
snackbar.setAction(R.string.action_retry) {
viewModel.obtainProfile()
}
snackbar.show()
}
}
})
observeImage(viewModel.avatarData, avatarPreview, avatarProgressBar)
observeImage(viewModel.headerData, headerPreview, headerProgressBar)
viewModel.saveData.observe(this, Observer<Resource<Nothing>> {
when(it) {
is Success -> {
finish()
}
is Loading -> {
saveProgressBar.visibility = View.VISIBLE
}
is Error -> {
onSaveFailure(it.errorMessage)
}
}
})
}
override fun onSaveInstanceState(outState: Bundle) {
outState.run {
putString(KEY_OLD_DISPLAY_NAME, oldDisplayName)
putString(KEY_OLD_NOTE, oldNote)
putBoolean(KEY_OLD_LOCKED, oldLocked)
putBoolean(KEY_IS_SAVING, isSaving)
putSerializable(KEY_CURRENTLY_PICKING, currentlyPicking)
putBoolean(KEY_AVATAR_CHANGED, avatarChanged)
putBoolean(KEY_HEADER_CHANGED, headerChanged)
override fun onStop() {
super.onStop()
if(!isFinishing) {
viewModel.updateProfile(displayNameEditText.text.toString(),
noteEditText.text.toString(),
lockedCheckBox.isChecked,
accountFieldEditAdapter.getFieldData())
}
super.onSaveInstanceState(outState)
}
private fun onAccountVerifyCredentialsFailed() {
Log.e(TAG, "The account failed to load.")
private fun observeImage(liveData: LiveData<Resource<Bitmap>>, imageView: ImageView, progressBar: View) {
liveData.observe(this, Observer<Resource<Bitmap>> {
when (it) {
is Success -> {
imageView.setImageBitmap(it.data)
imageView.show()
progressBar.hide()
}
is Loading -> {
progressBar.show()
}
is Error -> {
progressBar.hide()
if(!it.consumed) {
onResizeFailure()
it.consumed = true
}
}
}
})
}
private fun onMediaPick(pickType: PickType) {
@ -245,77 +266,20 @@ class EditProfileActivity : BaseActivity(), Injectable {
}
private fun save() {
if (isSaving || currentlyPicking != PickType.NOTHING) {
return
if (currentlyPicking != PickType.NOTHING) {
return
}
isSaving = true
saveProgressBar.visibility = View.VISIBLE
val newDisplayName = displayNameEditText.text.toString()
val displayName = if (oldDisplayName == newDisplayName) {
null
} else {
RequestBody.create(MultipartBody.FORM, newDisplayName)
}
val newNote = noteEditText.text.toString()
val note = if (oldNote == newNote) {
null
} else {
RequestBody.create(MultipartBody.FORM, newNote)
}
val newLocked = lockedCheckBox.isChecked
val locked = if (oldLocked == newLocked) {
null
} else {
RequestBody.create(MultipartBody.FORM, newLocked.toString())
}
val avatar = if (avatarChanged) {
val avatarBody = RequestBody.create(MediaType.parse("image/png"), getCacheFileForName(AVATAR_FILE_NAME))
MultipartBody.Part.createFormData("avatar", getFileName(), avatarBody)
} else {
null
}
val header = if (headerChanged) {
val headerBody = RequestBody.create(MediaType.parse("image/png"), getCacheFileForName(HEADER_FILE_NAME))
MultipartBody.Part.createFormData("header", getFileName(), headerBody)
} else {
null
}
if (displayName == null && note == null && locked == null && avatar == null && header == null) {
/** if nothing has changed, there is no need to make a network request */
setResult(Activity.RESULT_OK)
finish()
return
}
mastodonApi.accountUpdateCredentials(displayName, note, locked, avatar, header).enqueue(object : Callback<Account> {
override fun onResponse(call: Call<Account>, response: Response<Account>) {
if (!response.isSuccessful) {
onSaveFailure()
return
}
privatePreferences.edit()
.putBoolean("refreshProfileHeader", true)
.apply()
setResult(Activity.RESULT_OK)
finish()
}
override fun onFailure(call: Call<Account>, t: Throwable) {
onSaveFailure()
}
})
viewModel.save(displayNameEditText.text.toString(),
noteEditText.text.toString(),
lockedCheckBox.isChecked,
accountFieldEditAdapter.getFieldData(),
this)
}
private fun onSaveFailure() {
isSaving = false
Snackbar.make(avatarButton, R.string.error_media_upload_sending, Snackbar.LENGTH_LONG).show()
private fun onSaveFailure(msg: String?) {
val errorMsg = msg ?: getString(R.string.error_media_upload_sending)
Snackbar.make(avatarButton, errorMsg, Snackbar.LENGTH_LONG).show()
saveProgressBar.visibility = View.GONE
}
@ -350,6 +314,7 @@ class EditProfileActivity : BaseActivity(), Injectable {
if (resultCode == Activity.RESULT_OK && data != null) {
CropImage.activity(data.data)
.setInitialCropWindowPaddingRatio(0f)
.setOutputCompressFormat(Bitmap.CompressFormat.PNG)
.setAspectRatio(AVATAR_SIZE, AVATAR_SIZE)
.start(this)
} else {
@ -360,6 +325,7 @@ class EditProfileActivity : BaseActivity(), Injectable {
if (resultCode == Activity.RESULT_OK && data != null) {
CropImage.activity(data.data)
.setInitialCropWindowPaddingRatio(0f)
.setOutputCompressFormat(Bitmap.CompressFormat.PNG)
.setAspectRatio(HEADER_WIDTH, HEADER_HEIGHT)
.start(this)
} else {
@ -379,50 +345,21 @@ class EditProfileActivity : BaseActivity(), Injectable {
private fun beginResize(uri: Uri) {
beginMediaPicking()
val width: Int
val height: Int
val cacheFile: File
when (currentlyPicking) {
EditProfileActivity.PickType.AVATAR -> {
width = AVATAR_SIZE
height = AVATAR_SIZE
cacheFile = getCacheFileForName(AVATAR_FILE_NAME)
viewModel.newAvatar(uri, this)
}
EditProfileActivity.PickType.HEADER -> {
width = HEADER_WIDTH
height = HEADER_HEIGHT
cacheFile = getCacheFileForName(HEADER_FILE_NAME)
viewModel.newHeader(uri, this)
}
else -> {
throw AssertionError("PickType not set.")
}
}
ResizeImageTask(contentResolver, width, height, cacheFile, object : ResizeImageTask.Listener {
override fun onSuccess(resizedImage: Bitmap?) {
val pickType = currentlyPicking
endMediaPicking()
when (pickType) {
EditProfileActivity.PickType.AVATAR -> {
avatarPreview.setImageBitmap(resizedImage)
avatarPreview.visibility = View.VISIBLE
avatarButton.setImageResource(R.drawable.ic_add_a_photo_32dp)
avatarChanged = true
}
EditProfileActivity.PickType.HEADER -> {
headerPreview.setImageBitmap(resizedImage)
headerPreview.visibility = View.VISIBLE
headerButton.setImageResource(R.drawable.ic_add_a_photo_32dp)
headerChanged = true
}
EditProfileActivity.PickType.NOTHING -> { /* do nothing */ }
}
}
currentlyPicking = PickType.NOTHING
override fun onFailure() {
onResizeFailure()
}
}).execute(uri)
}
private fun onResizeFailure() {
@ -430,80 +367,4 @@ class EditProfileActivity : BaseActivity(), Injectable {
endMediaPicking()
}
private fun getCacheFileForName(filename: String): File {
return File(cacheDir, filename)
}
private fun getFileName(): String {
return java.lang.Long.toHexString(Random().nextLong())
}
private class ResizeImageTask(private val contentResolver: ContentResolver,
private val resizeWidth: Int,
private val resizeHeight: Int,
private val cacheFile: File,
private val listener: Listener) : AsyncTask<Uri, Void, Boolean>() {
private var resultBitmap: Bitmap? = null
override fun doInBackground(vararg uris: Uri): Boolean? {
val uri = uris[0]
val sourceBitmap = MediaUtils.getSampledBitmap(contentResolver, uri, resizeWidth, resizeHeight)
if (sourceBitmap == null) {
return false
}
//dont upscale image if its smaller than the desired size
val bitmap =
if (sourceBitmap.width <= resizeWidth && sourceBitmap.height <= resizeHeight) {
sourceBitmap
} else {
Bitmap.createScaledBitmap(sourceBitmap, resizeWidth, resizeHeight, true)
}
resultBitmap = bitmap
if (!saveBitmapToFile(bitmap, cacheFile)) {
return false
}
if (isCancelled) {
return false
}
return true
}
override fun onPostExecute(successful: Boolean) {
if (successful) {
listener.onSuccess(resultBitmap)
} else {
listener.onFailure()
}
}
fun saveBitmapToFile(bitmap: Bitmap, file: File): Boolean {
val outputStream: OutputStream
try {
outputStream = FileOutputStream(file)
} catch (e: FileNotFoundException) {
Log.w(TAG, Log.getStackTraceString(e))
return false
}
bitmap.compress(Bitmap.CompressFormat.PNG, 100, outputStream)
IOUtils.closeQuietly(outputStream)
return true
}
internal interface Listener {
fun onSuccess(resizedImage: Bitmap?)
fun onFailure()
}
}
}