From f022944e904fe5d591787e3c6b57f03e742b2a92 Mon Sep 17 00:00:00 2001 From: Konrad Pozniak 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 76790806e..23e1cbeca 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 0b7068f43..ce88d2e14 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 { - override fun onResponse(call: Call, response: Response) { - 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, t: Throwable) { - onAccountVerifyCredentialsFailed() + scrollView.post{ + scrollView.smoothScrollTo(0, it.bottom) + } + } + + viewModel.obtainProfile() + + viewModel.profileData.observe(this, Observer> { 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> { + 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>, imageView: ImageView, progressBar: View) { + liveData.observe(this, Observer> { + + 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 { - override fun onResponse(call: Call, response: Response) { - if (!response.isSuccessful) { - onSaveFailure() - return - } - privatePreferences.edit() - .putBoolean("refreshProfileHeader", true) - .apply() - setResult(Activity.RESULT_OK) - finish() - } - - override fun onFailure(call: Call, 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() { - 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 df23fc59a..d2758940a 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 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 000000000..f265a4e63 --- /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 . */ + +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() { + + private val fieldData = mutableListOf() + + fun setFields(fields: List) { + fieldData.clear() + + fields.forEach { field -> + fieldData.add(MutableStringPair(field.name, field.value)) + } + if(fieldData.isEmpty()) { + fieldData.add(MutableStringPair("", "")) + } + + notifyDataSetChanged() + } + + fun getFieldData(): List { + 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 b502888db..cc70e5ce3 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 95015e60f..35a74e688 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, Provider>) : ViewModelProvider.Factory { - + @Suppress("UNCHECKED_CAST") override fun create(modelClass: Class): 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 d54ab179b..d9e216d30 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? ): Parcelable @Parcelize data class Field ( - val name:String, + val name: String, val value: @WriteWith() Spanned ): Parcelable +@Parcelize +data class StringField ( + val name: String, + val value: String +): Parcelable + object SpannedParceler : Parceler { 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 20b373f76..0e56aced7 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> searchAccounts( @Query("q") String q, @Query("resolve") Boolean resolve, @Query("limit") Integer limit); + @GET("api/v1/accounts/{id}") Call 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 14e458a1c..d6117c9b3 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 (override val data: T? = null) : Resource(data) class Success (override val data: T? = null) : Resource(data) -class Error (override val data: T? = null, val errorMessage: String? = null): Resource(data) \ No newline at end of file +class Error (override val data: T? = null, + val errorMessage: String? = null, + var consumed: Boolean = false +): Resource(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 10bb83551..7fd9b9760 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>() private val callList: MutableList> = 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 000000000..96e46586b --- /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 . */ + +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>() + val avatarData = MutableLiveData>() + val headerData = MutableLiveData>() + val saveData = MutableLiveData>() + + private var oldProfileData: Account? = null + + private val callList: MutableList> = mutableListOf() + + fun obtainProfile() { + if(profileData.value == null || profileData.value is Error) { + + profileData.postValue(Loading()) + + val call = mastodonApi.accountVerifyCredentials() + call.enqueue(object : Callback { + override fun onResponse(call: Call, + response: Response) { + if (response.isSuccessful) { + val profile = response.body() + oldProfileData = profile + profileData.postValue(Success(profile)) + } else { + profileData.postValue(Error()) + } + } + + override fun onFailure(call: Call, 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>) { + + 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, 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 { + override fun onResponse(call: Call, response: Response) { + 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, t: Throwable) { + saveData.postValue(Error()) + } + }) + + } + + // 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)) + } + + } + + + private fun calculateFieldToUpdate(newField: StringField?, fieldsUnchanged: Boolean): Pair? { + 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 90a8d8bd8..b87b1e5ac 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 @@ - @@ -47,98 +48,130 @@ - + android:layout_gravity="center_horizontal" + android:layout_marginTop="-40dp" + android:orientation="vertical"> - - - - - + android:layout_marginStart="16dp"> - + - + - + + + + + + + + + + + + + + + + + android:layout_marginTop="30dp" + android:paddingStart="8dp" + android:text="@string/lock_account_label" + android:textSize="?attr/status_text_medium" /> - - - - - + + + + + +