From f022944e904fe5d591787e3c6b57f03e742b2a92 Mon Sep 17 00:00:00 2001
From: Konrad Pozniak <connyduck@users.noreply.github.com>
Date: Wed, 15 Aug 2018 20:47:09 +0200
Subject: [PATCH] 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
---
 .../keylesspalace/tusky/AccountActivity.kt    |  14 +-
 .../tusky/EditProfileActivity.kt              | 433 ++++++------------
 .../com/keylesspalace/tusky/MainActivity.java |  29 +-
 .../tusky/adapter/AccountFieldEditAdapter.kt  |  98 ++++
 .../appstore/{statusEvents.kt => Events.kt}   |   4 +-
 .../tusky/di/ViewModelFactory.kt              |   8 +-
 .../com/keylesspalace/tusky/entity/Account.kt |  11 +-
 .../tusky/network/MastodonApi.java            |  11 +-
 .../com/keylesspalace/tusky/util/Resource.kt  |   5 +-
 .../tusky/viewmodel/AccountViewModel.kt       |  13 +-
 .../tusky/viewmodel/EditProfileViewModel.kt   | 271 +++++++++++
 .../main/res/layout/activity_edit_profile.xml | 179 +++++---
 app/src/main/res/layout/item_edit_field.xml   |  36 ++
 app/src/main/res/values-night/styles.xml      |   3 +-
 app/src/main/res/values/strings.xml           |   4 +
 app/src/main/res/values/styles.xml            |   3 +-
 16 files changed, 727 insertions(+), 395 deletions(-)
 create mode 100644 app/src/main/java/com/keylesspalace/tusky/adapter/AccountFieldEditAdapter.kt
 rename app/src/main/java/com/keylesspalace/tusky/appstore/{statusEvents.kt => Events.kt} (82%)
 create mode 100644 app/src/main/java/com/keylesspalace/tusky/viewmodel/EditProfileViewModel.kt
 create mode 100644 app/src/main/res/layout/item_edit_field.xml

diff --git a/app/src/main/java/com/keylesspalace/tusky/AccountActivity.kt b/app/src/main/java/com/keylesspalace/tusky/AccountActivity.kt
index 76790806..23e1cbec 100644
--- a/app/src/main/java/com/keylesspalace/tusky/AccountActivity.kt
+++ b/app/src/main/java/com/keylesspalace/tusky/AccountActivity.kt
@@ -16,7 +16,6 @@
 package com.keylesspalace.tusky
 
 import android.animation.ArgbEvaluator
-import android.app.Activity
 import android.app.AlertDialog
 import android.arch.lifecycle.Observer
 import android.arch.lifecycle.ViewModelProviders
@@ -376,7 +375,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasSupportF
             accountFollowButton.setOnClickListener { _ ->
                 if (isSelf) {
                     val intent = Intent(this@AccountActivity, EditProfileActivity::class.java)
-                    startActivityForResult(intent, EDIT_ACCOUNT)
+                    startActivity(intent)
                     return@setOnClickListener
                 }
                 when (followState) {
@@ -395,15 +394,6 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasSupportF
         }
     }
 
-    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
-        super.onActivityResult(requestCode, resultCode, data)
-
-        //reload account when returning from EditProfileActivity
-        if(requestCode == EDIT_ACCOUNT && resultCode == Activity.RESULT_OK) {
-            viewModel.obtainAccount(accountId, true)
-        }
-    }
-
     override fun onSaveInstanceState(outState: Bundle) {
         outState.putString(KEY_ACCOUNT_ID, accountId)
         super.onSaveInstanceState(outState)
@@ -610,8 +600,6 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasSupportF
 
     companion object {
 
-        private const val EDIT_ACCOUNT = 1457
-
         private const val KEY_ACCOUNT_ID = "id"
         private val argbEvaluator = ArgbEvaluator()
 
diff --git a/app/src/main/java/com/keylesspalace/tusky/EditProfileActivity.kt b/app/src/main/java/com/keylesspalace/tusky/EditProfileActivity.kt
index 0b7068f4..ce88d2e1 100644
--- a/app/src/main/java/com/keylesspalace/tusky/EditProfileActivity.kt
+++ b/app/src/main/java/com/keylesspalace/tusky/EditProfileActivity.kt
@@ -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()
-        }
-    }
-
 }
diff --git a/app/src/main/java/com/keylesspalace/tusky/MainActivity.java b/app/src/main/java/com/keylesspalace/tusky/MainActivity.java
index df23fc59..d2758940 100644
--- a/app/src/main/java/com/keylesspalace/tusky/MainActivity.java
+++ b/app/src/main/java/com/keylesspalace/tusky/MainActivity.java
@@ -15,8 +15,8 @@
 
 package com.keylesspalace.tusky;
 
+import android.arch.lifecycle.Lifecycle;
 import android.content.Intent;
-import android.content.SharedPreferences;
 import android.graphics.Color;
 import android.graphics.drawable.Drawable;
 import android.net.Uri;
@@ -36,6 +36,8 @@ import android.view.KeyEvent;
 import android.widget.ImageButton;
 import android.widget.ImageView;
 
+import com.keylesspalace.tusky.appstore.EventHub;
+import com.keylesspalace.tusky.appstore.ProfileEditedEvent;
 import com.keylesspalace.tusky.db.AccountEntity;
 import com.keylesspalace.tusky.entity.Account;
 import com.keylesspalace.tusky.interfaces.ActionButtonActivity;
@@ -67,10 +69,14 @@ import javax.inject.Inject;
 import dagger.android.AndroidInjector;
 import dagger.android.DispatchingAndroidInjector;
 import dagger.android.support.HasSupportFragmentInjector;
+import io.reactivex.android.schedulers.AndroidSchedulers;
 import retrofit2.Call;
 import retrofit2.Callback;
 import retrofit2.Response;
 
+import static com.uber.autodispose.AutoDispose.autoDisposable;
+import static com.uber.autodispose.android.lifecycle.AndroidLifecycleScopeProvider.from;
+
 public final class MainActivity extends BottomSheetActivity implements ActionButtonActivity,
         HasSupportFragmentInjector {
 
@@ -90,6 +96,8 @@ public final class MainActivity extends BottomSheetActivity implements ActionBut
 
     @Inject
     public DispatchingAndroidInjector<Fragment> fragmentInjector;
+    @Inject
+    public EventHub eventHub;
 
     private FloatingActionButton composeButton;
     private AccountHeader headerResult;
@@ -211,6 +219,15 @@ public final class MainActivity extends BottomSheetActivity implements ActionBut
             disablePushNotifications();
         }
 
+        eventHub.getEvents()
+                .observeOn(AndroidSchedulers.mainThread())
+                .as(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY)))
+                .subscribe(event -> {
+                    if (event instanceof ProfileEditedEvent) {
+                        onFetchUserInfoSuccess(((ProfileEditedEvent) event).getNewProfileData());
+                    }
+                });
+
     }
 
     @Override
@@ -219,16 +236,6 @@ public final class MainActivity extends BottomSheetActivity implements ActionBut
 
         NotificationHelper.clearNotificationsForActiveAccount(this, accountManager);
 
-        /* After editing a profile, the profile header in the navigation drawer needs to be
-         * refreshed */
-        SharedPreferences preferences = getPrivatePreferences();
-        if (preferences.getBoolean("refreshProfileHeader", false)) {
-            fetchUserInfo();
-            preferences.edit()
-                    .putBoolean("refreshProfileHeader", false)
-                    .apply();
-        }
-
     }
 
     @Override
diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/AccountFieldEditAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/AccountFieldEditAdapter.kt
new file mode 100644
index 00000000..f265a4e6
--- /dev/null
+++ b/app/src/main/java/com/keylesspalace/tusky/adapter/AccountFieldEditAdapter.kt
@@ -0,0 +1,98 @@
+/* Copyright 2018 Conny Duck
+ *
+ * This file is a part of Tusky.
+ *
+ * This program is free software; you can redistribute it and/or modify it under the terms of the
+ * GNU General Public License as published by the Free Software Foundation; either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
+ * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
+ * Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with Tusky; if not,
+ * see <http://www.gnu.org/licenses>. */
+
+package com.keylesspalace.tusky.adapter
+
+import android.support.v7.widget.RecyclerView
+import android.text.Editable
+import android.text.TextWatcher
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.EditText
+import com.keylesspalace.tusky.R
+import com.keylesspalace.tusky.entity.StringField
+import kotlinx.android.synthetic.main.item_edit_field.view.*
+
+class AccountFieldEditAdapter : RecyclerView.Adapter<AccountFieldEditAdapter.ViewHolder>() {
+
+    private val fieldData = mutableListOf<MutableStringPair>()
+
+    fun setFields(fields: List<StringField>) {
+        fieldData.clear()
+
+        fields.forEach { field ->
+            fieldData.add(MutableStringPair(field.name, field.value))
+        }
+        if(fieldData.isEmpty()) {
+            fieldData.add(MutableStringPair("", ""))
+        }
+
+        notifyDataSetChanged()
+    }
+
+    fun getFieldData(): List<StringField> {
+        return fieldData.map {
+            StringField(it.first, it.second)
+        }
+    }
+
+    fun addField() {
+        fieldData.add(MutableStringPair("", ""))
+        notifyItemInserted(fieldData.size - 1)
+    }
+
+    override fun getItemCount(): Int = fieldData.size
+
+    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AccountFieldEditAdapter.ViewHolder {
+        val view = LayoutInflater.from(parent.context).inflate(R.layout.item_edit_field, parent, false)
+        return ViewHolder(view)
+    }
+
+    override fun onBindViewHolder(viewHolder: AccountFieldEditAdapter.ViewHolder, position: Int) {
+        viewHolder.nameTextView.setText(fieldData[position].first)
+        viewHolder.valueTextView.setText(fieldData[position].second)
+
+        viewHolder.nameTextView.addTextChangedListener(object: TextWatcher {
+            override fun afterTextChanged(newText: Editable) {
+                fieldData[viewHolder.adapterPosition].first = newText.toString()
+            }
+
+            override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) {}
+
+            override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) {}
+        })
+
+        viewHolder.valueTextView.addTextChangedListener(object: TextWatcher {
+            override fun afterTextChanged(newText: Editable) {
+                fieldData[viewHolder.adapterPosition].second = newText.toString()
+            }
+
+            override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) {}
+
+            override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) {}
+        })
+
+    }
+
+    class ViewHolder(rootView: View) : RecyclerView.ViewHolder(rootView) {
+        val nameTextView: EditText = rootView.accountFieldName
+        val valueTextView: EditText = rootView.accountFieldValue
+    }
+
+    class MutableStringPair (var first: String, var second: String)
+
+
+}
diff --git a/app/src/main/java/com/keylesspalace/tusky/appstore/statusEvents.kt b/app/src/main/java/com/keylesspalace/tusky/appstore/Events.kt
similarity index 82%
rename from app/src/main/java/com/keylesspalace/tusky/appstore/statusEvents.kt
rename to app/src/main/java/com/keylesspalace/tusky/appstore/Events.kt
index b502888d..cc70e5ce 100644
--- a/app/src/main/java/com/keylesspalace/tusky/appstore/statusEvents.kt
+++ b/app/src/main/java/com/keylesspalace/tusky/appstore/Events.kt
@@ -1,5 +1,6 @@
 package com.keylesspalace.tusky.appstore
 
+import com.keylesspalace.tusky.entity.Account
 import com.keylesspalace.tusky.entity.Status
 
 data class FavoriteEvent(val statusId: String, val favourite: Boolean) : Dispatchable
@@ -8,4 +9,5 @@ data class UnfollowEvent(val accountId: String) : Dispatchable
 data class BlockEvent(val accountId: String) : Dispatchable
 data class MuteEvent(val accountId: String) : Dispatchable
 data class StatusDeletedEvent(val statusId: String) : Dispatchable
-data class StatusComposedEvent(val status: Status) : Dispatchable
\ No newline at end of file
+data class StatusComposedEvent(val status: Status) : Dispatchable
+data class ProfileEditedEvent(val newProfileData: Account) : Dispatchable
\ No newline at end of file
diff --git a/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt b/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt
index 95015e60..35a74e68 100644
--- a/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt
+++ b/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt
@@ -5,6 +5,7 @@ package com.keylesspalace.tusky.di
 import android.arch.lifecycle.ViewModel
 import android.arch.lifecycle.ViewModelProvider
 import com.keylesspalace.tusky.viewmodel.AccountViewModel
+import com.keylesspalace.tusky.viewmodel.EditProfileViewModel
 import dagger.Binds
 import dagger.MapKey
 import dagger.Module
@@ -16,7 +17,7 @@ import kotlin.reflect.KClass
 
 @Singleton
 class ViewModelFactory @Inject constructor(private val viewModels: MutableMap<Class<out ViewModel>, Provider<ViewModel>>) : ViewModelProvider.Factory {
-
+    @Suppress("UNCHECKED_CAST")
     override fun <T : ViewModel> create(modelClass: Class<T>): T = viewModels[modelClass]?.get() as T
 }
 
@@ -36,5 +37,10 @@ abstract class ViewModelModule {
     @ViewModelKey(AccountViewModel::class)
     internal abstract fun accountViewModel(viewModel: AccountViewModel): ViewModel
 
+    @Binds
+    @IntoMap
+    @ViewModelKey(EditProfileViewModel::class)
+    internal abstract fun editProfileViewModel(viewModel: EditProfileViewModel): ViewModel
+
     //Add more ViewModels here
 }
\ No newline at end of file
diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Account.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Account.kt
index d54ab179..d9e216d3 100644
--- a/app/src/main/java/com/keylesspalace/tusky/entity/Account.kt
+++ b/app/src/main/java/com/keylesspalace/tusky/entity/Account.kt
@@ -70,15 +70,22 @@ data class Account(
 data class AccountSource(
         val privacy: Status.Visibility,
         val sensitive: Boolean,
-        val note: String
+        val note: String,
+        val fields: List<StringField>?
 ): Parcelable
 
 @Parcelize
 data class Field (
-        val name:String,
+        val name: String,
         val value: @WriteWith<SpannedParceler>() Spanned
 ): Parcelable
 
+@Parcelize
+data class StringField (
+        val name: String,
+        val value: String
+): Parcelable
+
 object SpannedParceler : Parceler<Spanned> {
     override fun create(parcel: Parcel): Spanned = HtmlUtils.fromHtml(parcel.readString())
 
diff --git a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.java b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.java
index 20b373f7..0e56aced 100644
--- a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.java
+++ b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.java
@@ -166,13 +166,22 @@ public interface MastodonApi {
             @Nullable @Part(value="note") RequestBody note,
             @Nullable @Part(value="locked") RequestBody locked,
             @Nullable @Part MultipartBody.Part avatar,
-            @Nullable @Part MultipartBody.Part header);
+            @Nullable @Part MultipartBody.Part header,
+            @Nullable @Part(value="fields_attributes[0][name]") RequestBody fieldName0,
+            @Nullable @Part(value="fields_attributes[0][value]") RequestBody fieldValue0,
+            @Nullable @Part(value="fields_attributes[1][name]") RequestBody fieldName1,
+            @Nullable @Part(value="fields_attributes[1][value]") RequestBody fieldValue1,
+            @Nullable @Part(value="fields_attributes[2][name]") RequestBody fieldName2,
+            @Nullable @Part(value="fields_attributes[2][value]") RequestBody fieldValue2,
+            @Nullable @Part(value="fields_attributes[3][name]") RequestBody fieldName3,
+            @Nullable @Part(value="fields_attributes[3][value]") RequestBody fieldValue3);
 
     @GET("api/v1/accounts/search")
     Call<List<Account>> searchAccounts(
             @Query("q") String q,
             @Query("resolve") Boolean resolve,
             @Query("limit") Integer limit);
+
     @GET("api/v1/accounts/{id}")
     Call<Account> account(@Path("id") String accountId);
 
diff --git a/app/src/main/java/com/keylesspalace/tusky/util/Resource.kt b/app/src/main/java/com/keylesspalace/tusky/util/Resource.kt
index 14e458a1..d6117c9b 100644
--- a/app/src/main/java/com/keylesspalace/tusky/util/Resource.kt
+++ b/app/src/main/java/com/keylesspalace/tusky/util/Resource.kt
@@ -6,4 +6,7 @@ class Loading<T> (override val data: T? = null) : Resource<T>(data)
 
 class Success<T> (override val data: T? = null) : Resource<T>(data)
 
-class Error<T> (override val data: T? = null, val errorMessage: String? = null): Resource<T>(data)
\ No newline at end of file
+class Error<T> (override val data: T? = null,
+                val errorMessage: String? = null,
+                var consumed: Boolean = false
+): Resource<T>(data)
\ No newline at end of file
diff --git a/app/src/main/java/com/keylesspalace/tusky/viewmodel/AccountViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/viewmodel/AccountViewModel.kt
index 10bb8355..7fd9b976 100644
--- a/app/src/main/java/com/keylesspalace/tusky/viewmodel/AccountViewModel.kt
+++ b/app/src/main/java/com/keylesspalace/tusky/viewmodel/AccountViewModel.kt
@@ -2,10 +2,7 @@ package com.keylesspalace.tusky.viewmodel
 
 import android.arch.lifecycle.MutableLiveData
 import android.arch.lifecycle.ViewModel
-import com.keylesspalace.tusky.appstore.BlockEvent
-import com.keylesspalace.tusky.appstore.EventHub
-import com.keylesspalace.tusky.appstore.MuteEvent
-import com.keylesspalace.tusky.appstore.UnfollowEvent
+import com.keylesspalace.tusky.appstore.*
 import com.keylesspalace.tusky.entity.Account
 import com.keylesspalace.tusky.entity.Relationship
 import com.keylesspalace.tusky.network.MastodonApi
@@ -13,6 +10,7 @@ import com.keylesspalace.tusky.util.Error
 import com.keylesspalace.tusky.util.Loading
 import com.keylesspalace.tusky.util.Resource
 import com.keylesspalace.tusky.util.Success
+import io.reactivex.disposables.Disposable
 import retrofit2.Call
 import retrofit2.Callback
 import retrofit2.Response
@@ -27,6 +25,12 @@ class AccountViewModel  @Inject constructor(
     val relationshipData = MutableLiveData<Resource<Relationship>>()
 
     private val callList: MutableList<Call<*>> = mutableListOf()
+    private val disposable: Disposable = eventHub.events
+                .subscribe { event ->
+                    if (event is ProfileEditedEvent && event.newProfileData.id == accountData.value?.data?.id) {
+                        accountData.postValue(Success(event.newProfileData))
+                    }
+                }
 
 
     fun obtainAccount(accountId: String, reload: Boolean = false) {
@@ -182,6 +186,7 @@ class AccountViewModel  @Inject constructor(
         callList.forEach {
             it.cancel()
         }
+        disposable.dispose()
     }
 
     enum class RelationShipAction {
diff --git a/app/src/main/java/com/keylesspalace/tusky/viewmodel/EditProfileViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/viewmodel/EditProfileViewModel.kt
new file mode 100644
index 00000000..96e46586
--- /dev/null
+++ b/app/src/main/java/com/keylesspalace/tusky/viewmodel/EditProfileViewModel.kt
@@ -0,0 +1,271 @@
+/* Copyright 2018 Conny Duck
+ *
+ * This file is a part of Tusky.
+ *
+ * This program is free software; you can redistribute it and/or modify it under the terms of the
+ * GNU General Public License as published by the Free Software Foundation; either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
+ * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
+ * Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with Tusky; if not,
+ * see <http://www.gnu.org/licenses>. */
+
+package com.keylesspalace.tusky.viewmodel
+
+import android.arch.lifecycle.MutableLiveData
+import android.arch.lifecycle.ViewModel
+import android.content.Context
+import android.graphics.Bitmap
+import android.net.Uri
+import android.util.Log
+import com.keylesspalace.tusky.EditProfileActivity.Companion.AVATAR_SIZE
+import com.keylesspalace.tusky.EditProfileActivity.Companion.HEADER_HEIGHT
+import com.keylesspalace.tusky.EditProfileActivity.Companion.HEADER_WIDTH
+import com.keylesspalace.tusky.appstore.EventHub
+import com.keylesspalace.tusky.appstore.ProfileEditedEvent
+import com.keylesspalace.tusky.entity.Account
+import com.keylesspalace.tusky.entity.StringField
+import com.keylesspalace.tusky.network.MastodonApi
+import com.keylesspalace.tusky.util.*
+import io.reactivex.Single
+import io.reactivex.schedulers.Schedulers
+import okhttp3.MediaType
+import okhttp3.MultipartBody
+import okhttp3.RequestBody
+import org.json.JSONObject
+import retrofit2.Call
+import retrofit2.Callback
+import retrofit2.Response
+import java.io.File
+import java.io.FileNotFoundException
+import java.io.FileOutputStream
+import java.io.OutputStream
+import javax.inject.Inject
+
+private const val HEADER_FILE_NAME = "header.png"
+private const val AVATAR_FILE_NAME = "avatar.png"
+
+private const val TAG = "EditProfileViewModel"
+
+class EditProfileViewModel  @Inject constructor(
+            private val mastodonApi: MastodonApi,
+            private val eventHub: EventHub
+    ): ViewModel() {
+
+    val profileData = MutableLiveData<Resource<Account>>()
+    val avatarData = MutableLiveData<Resource<Bitmap>>()
+    val headerData = MutableLiveData<Resource<Bitmap>>()
+    val saveData = MutableLiveData<Resource<Nothing>>()
+
+    private var oldProfileData: Account? = null
+
+    private val callList: MutableList<Call<*>> = mutableListOf()
+
+    fun obtainProfile() {
+        if(profileData.value == null || profileData.value is Error) {
+
+            profileData.postValue(Loading())
+
+            val call = mastodonApi.accountVerifyCredentials()
+                    call.enqueue(object : Callback<Account> {
+                override fun onResponse(call: Call<Account>,
+                                        response: Response<Account>) {
+                    if (response.isSuccessful) {
+                        val profile = response.body()
+                        oldProfileData = profile
+                        profileData.postValue(Success(profile))
+                    } else {
+                        profileData.postValue(Error())
+                    }
+                }
+
+                override fun onFailure(call: Call<Account>, t: Throwable) {
+                    profileData.postValue(Error())
+                }
+            })
+
+            callList.add(call)
+        }
+    }
+
+    fun newAvatar(uri: Uri, context: Context) {
+        val cacheFile = getCacheFileForName(context, AVATAR_FILE_NAME)
+
+        resizeImage(uri, context, AVATAR_SIZE, AVATAR_SIZE, cacheFile, avatarData)
+    }
+
+    fun newHeader(uri: Uri, context: Context) {
+        val cacheFile = getCacheFileForName(context, HEADER_FILE_NAME)
+
+        resizeImage(uri, context, HEADER_WIDTH, HEADER_HEIGHT, cacheFile, headerData)
+    }
+
+    private fun resizeImage(uri: Uri,
+                            context: Context,
+                            resizeWidth: Int,
+                            resizeHeight: Int,
+                            cacheFile: File,
+                            imageLiveData: MutableLiveData<Resource<Bitmap>>) {
+
+        Single.fromCallable {
+            val contentResolver = context.contentResolver
+            val sourceBitmap = MediaUtils.getSampledBitmap(contentResolver, uri, resizeWidth, resizeHeight)
+
+            if (sourceBitmap == null) {
+                throw Exception()
+            }
+
+            //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)
+                    }
+
+            if (!saveBitmapToFile(bitmap, cacheFile)) {
+                throw Exception()
+            }
+
+            bitmap
+        }.subscribeOn(Schedulers.io())
+                .subscribe({
+                    imageLiveData.postValue(Success(it))
+                }, {
+                    imageLiveData.postValue(Error())
+                })
+    }
+
+    fun save(newDisplayName: String, newNote: String, newLocked: Boolean, newFields: List<StringField>, context: Context) {
+
+        if(saveData.value is Loading || profileData.value !is Success) {
+            return
+        }
+
+        val displayName = if (oldProfileData?.displayName == newDisplayName) {
+            null
+        } else {
+            RequestBody.create(MultipartBody.FORM, newDisplayName)
+        }
+
+        val note = if (oldProfileData?.source?.note == newNote) {
+            null
+        } else {
+            RequestBody.create(MultipartBody.FORM, newNote)
+        }
+
+        val locked = if (oldProfileData?.locked == newLocked) {
+            null
+        } else {
+            RequestBody.create(MultipartBody.FORM, newLocked.toString())
+        }
+
+        val avatar = if (avatarData.value is Success && avatarData.value?.data != null) {
+            val avatarBody = RequestBody.create(MediaType.parse("image/png"), getCacheFileForName(context, AVATAR_FILE_NAME))
+            MultipartBody.Part.createFormData("avatar", StringUtils.randomAlphanumericString(12), avatarBody)
+        } else {
+            null
+        }
+
+        val header = if (headerData.value is Success && headerData.value?.data != null) {
+            val headerBody = RequestBody.create(MediaType.parse("image/png"), getCacheFileForName(context, HEADER_FILE_NAME))
+            MultipartBody.Part.createFormData("header", StringUtils.randomAlphanumericString(12), headerBody)
+        } else {
+            null
+        }
+
+        // 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)
+
+        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
+        }
+
+        mastodonApi.accountUpdateCredentials(displayName, note, locked, avatar, header,
+                field1?.first, field1?.second, field2?.first, field2?.second, field3?.first, field3?.second, field4?.first, field4?.second
+        ).enqueue(object : Callback<Account> {
+            override fun onResponse(call: Call<Account>, response: Response<Account>) {
+                val newProfileData = response.body()
+                if (!response.isSuccessful || newProfileData == null) {
+                    val errorResponse = response.errorBody()?.string()
+                    val errorMsg = if(!errorResponse.isNullOrBlank()) {
+                        JSONObject(errorResponse).optString("error", null)
+                    } else {
+                        null
+                    }
+                    saveData.postValue(Error(errorMessage = errorMsg))
+                    return
+                }
+                saveData.postValue(Success())
+                eventHub.dispatch(ProfileEditedEvent(newProfileData))
+            }
+
+            override fun onFailure(call: Call<Account>, t: Throwable) {
+                saveData.postValue(Error())
+            }
+        })
+
+    }
+
+    // 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>? {
+        if(fieldsUnchanged || newField == null || newField.name.isBlank()) {
+            return null
+        }
+        return Pair(
+                RequestBody.create(MultipartBody.FORM, newField.name),
+                RequestBody.create(MultipartBody.FORM, newField.value)
+        )
+    }
+
+    private fun getCacheFileForName(context: Context, filename: String): File {
+        return File(context.cacheDir, filename)
+    }
+
+    private 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
+    }
+
+    override fun onCleared() {
+        callList.forEach {
+            it.cancel()
+        }
+    }
+
+
+}
\ No newline at end of file
diff --git a/app/src/main/res/layout/activity_edit_profile.xml b/app/src/main/res/layout/activity_edit_profile.xml
index 90a8d8bd..b87b1e5a 100644
--- a/app/src/main/res/layout/activity_edit_profile.xml
+++ b/app/src/main/res/layout/activity_edit_profile.xml
@@ -8,7 +8,8 @@
 
     <include layout="@layout/toolbar_basic" />
 
-    <ScrollView
+    <android.support.v4.widget.NestedScrollView
+        android:id="@+id/scrollView"
         android:layout_width="match_parent"
         android:layout_height="match_parent"
         app:layout_behavior="@string/appbar_scrolling_view_behavior">
@@ -47,98 +48,130 @@
 
             </RelativeLayout>
 
-            <RelativeLayout
-                android:layout_width="wrap_content"
+
+            <LinearLayout
+                android:layout_width="@dimen/timeline_width"
                 android:layout_height="wrap_content"
-                android:layout_marginStart="16dp"
-                android:layout_marginTop="-40dp">
+                android:layout_gravity="center_horizontal"
+                android:layout_marginTop="-40dp"
+                android:orientation="vertical">
 
-                <com.keylesspalace.tusky.view.RoundedImageView
-                    android:id="@+id/avatarPreview"
-                    android:layout_width="80dp"
-                    android:layout_height="80dp"
-                    android:contentDescription="@null" />
-
-                <ImageButton
-                    android:id="@+id/avatarButton"
-                    android:layout_width="80dp"
-                    android:layout_height="80dp"
-                    android:background="@drawable/round_button"
-                    android:contentDescription="@string/label_avatar"
-                    android:elevation="4dp"
-                    app:srcCompat="@drawable/ic_add_a_photo_32dp" />
-
-                <ProgressBar
-                    android:id="@+id/avatarProgressBar"
+                <RelativeLayout
                     android:layout_width="wrap_content"
                     android:layout_height="wrap_content"
-                    android:layout_centerInParent="true"
-                    android:indeterminate="true"
-                    android:visibility="gone" />
+                    android:layout_marginStart="16dp">
 
-            </RelativeLayout>
+                    <com.keylesspalace.tusky.view.RoundedImageView
+                        android:id="@+id/avatarPreview"
+                        android:layout_width="80dp"
+                        android:layout_height="80dp"
+                        android:contentDescription="@null" />
 
-            <android.support.design.widget.TextInputLayout
-                android:id="@+id/layout_edit_profile_display_name"
-                android:layout_width="match_parent"
-                android:layout_height="wrap_content"
-                android:layout_marginTop="30dp">
+                    <ImageButton
+                        android:id="@+id/avatarButton"
+                        android:layout_width="80dp"
+                        android:layout_height="80dp"
+                        android:background="@drawable/round_button"
+                        android:contentDescription="@string/label_avatar"
+                        android:elevation="4dp"
+                        app:srcCompat="@drawable/ic_add_a_photo_32dp" />
 
-                <android.support.design.widget.TextInputEditText
-                    android:id="@+id/displayNameEditText"
+                    <ProgressBar
+                        android:id="@+id/avatarProgressBar"
+                        android:layout_width="wrap_content"
+                        android:layout_height="wrap_content"
+                        android:layout_centerInParent="true"
+                        android:indeterminate="true"
+                        android:visibility="gone" />
+
+                </RelativeLayout>
+
+                <android.support.design.widget.TextInputLayout
+                    android:layout_width="match_parent"
+                    android:layout_height="wrap_content"
+                    android:layout_marginTop="30dp">
+
+                    <android.support.design.widget.TextInputEditText
+                        android:id="@+id/displayNameEditText"
+                        android:layout_width="match_parent"
+                        android:layout_height="wrap_content"
+                        android:layout_marginEnd="16dp"
+                        android:layout_marginStart="16dp"
+                        android:hint="@string/hint_display_name"
+                        android:importantForAutofill="no" />
+
+                </android.support.design.widget.TextInputLayout>
+
+                <android.support.design.widget.TextInputLayout
+                    android:layout_width="match_parent"
+                    android:layout_height="wrap_content"
+                    android:layout_marginTop="30dp">
+
+                    <android.support.design.widget.TextInputEditText
+                        android:id="@+id/noteEditText"
+                        android:layout_width="match_parent"
+                        android:layout_height="wrap_content"
+                        android:layout_marginBottom="16dp"
+                        android:layout_marginEnd="16dp"
+                        android:layout_marginStart="16dp"
+                        android:hint="@string/hint_note"
+                        android:importantForAutofill="no" />
+
+                </android.support.design.widget.TextInputLayout>
+
+                <android.support.v7.widget.AppCompatCheckBox
+                    android:id="@+id/lockedCheckBox"
                     android:layout_width="match_parent"
                     android:layout_height="wrap_content"
                     android:layout_marginEnd="16dp"
                     android:layout_marginStart="16dp"
-                    android:hint="@string/hint_display_name"
-                    android:importantForAutofill="no"
-                    android:maxLength="30" />
+                    android:layout_marginTop="30dp"
+                    android:paddingStart="8dp"
+                    android:text="@string/lock_account_label"
+                    android:textSize="?attr/status_text_medium" />
 
-            </android.support.design.widget.TextInputLayout>
-
-            <android.support.design.widget.TextInputLayout
-                android:id="@+id/layout_edit_profile_note"
-                android:layout_width="match_parent"
-                android:layout_height="wrap_content"
-                android:layout_marginTop="30dp">
-
-                <android.support.design.widget.TextInputEditText
-                    android:id="@+id/noteEditText"
+                <TextView
                     android:layout_width="match_parent"
                     android:layout_height="wrap_content"
+                    android:layout_marginBottom="24dp"
+                    android:layout_marginEnd="16dp"
+                    android:layout_marginStart="16dp"
+                    android:paddingStart="40dp"
+                    android:text="@string/lock_account_label_description"
+                    android:textSize="?attr/status_text_small" />
+
+                <TextView
+                    android:layout_width="match_parent"
+                    android:layout_height="wrap_content"
+                    android:layout_marginBottom="8dp"
+                    android:layout_marginEnd="16dp"
+                    android:layout_marginStart="16dp"
+                    android:text="@string/profile_metadata_label"
+                    android:textSize="?attr/status_text_small" />
+
+                <android.support.v7.widget.RecyclerView
+                    android:id="@+id/fieldList"
+                    android:layout_width="match_parent"
+                    android:layout_height="wrap_content"
+                    android:clipChildren="false"
+                    android:nestedScrollingEnabled="false" />
+
+                <Button
+                    android:id="@+id/addFieldButton"
+                    android:layout_width="wrap_content"
+                    android:layout_height="wrap_content"
+                    android:layout_gravity="end"
                     android:layout_marginBottom="16dp"
                     android:layout_marginEnd="16dp"
                     android:layout_marginStart="16dp"
-                    android:hint="@string/hint_note"
-                    android:importantForAutofill="no"
-                    android:maxLength="160" />
-
-            </android.support.design.widget.TextInputLayout>
-
-            <android.support.v7.widget.AppCompatCheckBox
-                android:id="@+id/lockedCheckBox"
-                android:layout_width="match_parent"
-                android:layout_height="wrap_content"
-                android:layout_marginEnd="16dp"
-                android:layout_marginStart="16dp"
-                android:layout_marginTop="30dp"
-                android:paddingStart="8dp"
-                android:text="@string/lock_account_label"
-                android:textSize="?attr/status_text_medium" />
-
-            <TextView
-                android:layout_width="match_parent"
-                android:layout_height="wrap_content"
-                android:layout_marginBottom="24dp"
-                android:layout_marginEnd="16dp"
-                android:layout_marginStart="16dp"
-                android:paddingStart="40dp"
-                android:text="@string/lock_account_label_description"
-                android:textSize="?attr/status_text_small" />
+                    android:drawablePadding="6dp"
+                    android:text="@string/profile_metadata_add"
+                    android:textColor="#fff" />
 
+            </LinearLayout>
         </LinearLayout>
 
-    </ScrollView>
+    </android.support.v4.widget.NestedScrollView>
 
     <include layout="@layout/toolbar_shadow_shim" />
 
diff --git a/app/src/main/res/layout/item_edit_field.xml b/app/src/main/res/layout/item_edit_field.xml
new file mode 100644
index 00000000..fccd784c
--- /dev/null
+++ b/app/src/main/res/layout/item_edit_field.xml
@@ -0,0 +1,36 @@
+<?xml version="1.0" encoding="utf-8"?>
+<android.support.v7.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_width="match_parent"
+    android:layout_height="wrap_content"
+    android:orientation="horizontal"
+    android:layout_marginBottom="8dp"
+    android:layout_marginStart="16dp"
+    android:layout_marginEnd="16dp">
+    <LinearLayout
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:background="?android:colorBackground"
+        android:paddingStart="16dp"
+        android:paddingEnd="16dp"
+        android:paddingTop="8dp"
+        android:paddingBottom="8dp"
+        android:orientation="vertical">
+
+    <android.support.text.emoji.widget.EmojiEditText
+        android:id="@+id/accountFieldName"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:ellipsize="end"
+        android:textSize="?attr/status_text_medium"
+        android:hint="@string/profile_metadata_label_label" />
+
+    <android.support.text.emoji.widget.EmojiEditText
+        android:id="@+id/accountFieldValue"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:lineSpacingMultiplier="1.1"
+        android:textSize="?attr/status_text_medium"
+        android:hint="@string/profile_metadata_content_label" />
+    </LinearLayout>
+
+</android.support.v7.widget.CardView>
\ No newline at end of file
diff --git a/app/src/main/res/values-night/styles.xml b/app/src/main/res/values-night/styles.xml
index 5ffeebb1..55329bf7 100644
--- a/app/src/main/res/values-night/styles.xml
+++ b/app/src/main/res/values-night/styles.xml
@@ -6,7 +6,8 @@
         <item name="colorPrimary">@color/color_primary_dark</item>
         <item name="colorPrimaryDark">@color/color_primary_dark_dark</item>
         <item name="colorAccent">@color/color_accent_dark</item>
-        <item name="colorButtonNormal">@color/button_dark</item>
+        <item name="colorButtonNormal">@color/toolbar_background_dark</item>
+        <item name="android:buttonStyle">@style/Widget.AppCompat.Button.Colored</item>
 
         <item name="android:colorBackground">@color/color_primary_dark_dark</item>
         <item name="android:windowBackground">@color/window_background_dark</item>
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 8e6519a1..dad4c049 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -340,5 +340,9 @@
     <string name="license_description">Tusky contains code and assets from the following open source projects:</string>
     <string name="license_apache_2">Licensed under the Apache License (copy below)</string>
     <string name="license_cc_by_4">CC-BY 4.0</string>
+    <string name="profile_metadata_label">Profile metadata</string>
+    <string name="profile_metadata_add">add data</string>
+    <string name="profile_metadata_label_label">Label</string>
+    <string name="profile_metadata_content_label">Content</string>
 
 </resources>
diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml
index ebcd668e..b6d4e688 100644
--- a/app/src/main/res/values/styles.xml
+++ b/app/src/main/res/values/styles.xml
@@ -55,7 +55,8 @@
         <item name="colorPrimary">@color/color_primary_light</item>
         <item name="colorPrimaryDark">@color/color_primary_dark_light</item>
         <item name="colorAccent">@color/color_accent_light</item>
-        <item name="colorButtonNormal">@color/button_light</item>
+        <item name="colorButtonNormal">@color/color_primary_dark_light</item>
+        <item name="android:buttonStyle">@style/Widget.AppCompat.Button.Colored</item>
 
         <item name="android:colorBackground">@color/color_background_light</item>
         <item name="android:windowBackground">@color/window_background_light</item>