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

* refactor EditProfileActivity, add profile fields

* preserve transparency when cropping profile images

* dont validate profile fields on client side

* revert unintentional change in card_frame_dark.xml

* improve activity_edit_profile layout for tablets

* Revert "improve activity_edit_profile layout for tablets"

This reverts commit 20ff3d167c39b15566e017108b33fe58690a8482.

* improve activity_edit_profile layout for tablets

* fix bug in EditProfileActivity, add snackbar

* improve EditProfileActivity code

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

View file

@ -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()

View file

@ -17,72 +17,59 @@ package com.keylesspalace.tusky
import android.Manifest
import android.app.Activity
import android.content.ContentResolver
import android.arch.lifecycle.LiveData
import android.arch.lifecycle.Observer
import android.arch.lifecycle.ViewModelProviders
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Color
import android.net.Uri
import android.os.AsyncTask
import android.os.Bundle
import android.support.design.widget.Snackbar
import android.support.v4.app.ActivityCompat
import android.support.v4.content.ContextCompat
import android.util.Log
import android.support.v4.widget.TextViewCompat
import android.support.v7.widget.LinearLayoutManager
import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.widget.ImageView
import com.keylesspalace.tusky.adapter.AccountFieldEditAdapter
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.IOUtils
import com.keylesspalace.tusky.util.MediaUtils
import com.keylesspalace.tusky.util.*
import com.keylesspalace.tusky.viewmodel.EditProfileViewModel
import com.mikepenz.google_material_typeface_library.GoogleMaterial
import com.mikepenz.iconics.IconicsDrawable
import com.squareup.picasso.Picasso
import com.theartofdev.edmodo.cropper.CropImage
import kotlinx.android.synthetic.main.activity_edit_profile.*
import kotlinx.android.synthetic.main.toolbar_basic.*
import okhttp3.MediaType
import okhttp3.MultipartBody
import okhttp3.RequestBody
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response
import java.io.*
import java.util.*
import javax.inject.Inject
private const val TAG = "EditProfileActivity"
private const val HEADER_FILE_NAME = "header.png"
private const val AVATAR_FILE_NAME = "avatar.png"
private const val KEY_OLD_DISPLAY_NAME = "OLD_DISPLAY_NAME"
private const val KEY_OLD_NOTE = "OLD_NOTE"
private const val KEY_OLD_LOCKED = "OLD_LOCKED"
private const val KEY_IS_SAVING = "IS_SAVING"
private const val KEY_CURRENTLY_PICKING = "CURRENTLY_PICKING"
private const val KEY_AVATAR_CHANGED = "AVATAR_CHANGED"
private const val KEY_HEADER_CHANGED = "HEADER_CHANGED"
private const val AVATAR_PICK_RESULT = 1
private const val HEADER_PICK_RESULT = 2
private const val PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE = 1
private const val AVATAR_SIZE = 400
private const val HEADER_WIDTH = 700
private const val HEADER_HEIGHT = 335
class EditProfileActivity : BaseActivity(), Injectable {
private var oldDisplayName: String? = null
private var oldNote: String? = null
private var oldLocked: Boolean = false
private var isSaving: Boolean = false
private var currentlyPicking: PickType = PickType.NOTHING
private var avatarChanged: Boolean = false
private var headerChanged: Boolean = false
companion object {
const val AVATAR_SIZE = 400
const val HEADER_WIDTH = 700
const val HEADER_HEIGHT = 335
private const val AVATAR_PICK_RESULT = 1
private const val HEADER_PICK_RESULT = 2
private const val PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE = 1
private const val MAX_ACCOUNT_FIELDS = 4
}
@Inject
lateinit var mastodonApi: MastodonApi
lateinit var viewModelFactory: ViewModelFactory
private lateinit var viewModel: EditProfileViewModel
private var currentlyPicking: PickType = PickType.NOTHING
private val accountFieldEditAdapter = AccountFieldEditAdapter()
private enum class PickType {
NOTHING,
@ -94,93 +81,127 @@ class EditProfileActivity : BaseActivity(), Injectable {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_edit_profile)
viewModel = ViewModelProviders.of(this, viewModelFactory)[EditProfileViewModel::class.java]
setSupportActionBar(toolbar)
supportActionBar?.run {
setTitle(R.string.title_edit_profile)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
supportActionBar?.setDisplayShowHomeEnabled(true)
}
savedInstanceState?.let {
oldDisplayName = it.getString(KEY_OLD_DISPLAY_NAME)
oldNote = it.getString(KEY_OLD_NOTE)
oldLocked = it.getBoolean(KEY_OLD_LOCKED)
isSaving = it.getBoolean(KEY_IS_SAVING)
currentlyPicking = it.getSerializable(KEY_CURRENTLY_PICKING) as PickType
avatarChanged = it.getBoolean(KEY_AVATAR_CHANGED)
headerChanged = it.getBoolean(KEY_HEADER_CHANGED)
if (avatarChanged) {
val avatar = BitmapFactory.decodeFile(getCacheFileForName(AVATAR_FILE_NAME).absolutePath)
avatarPreview.setImageBitmap(avatar)
}
if (headerChanged) {
val header = BitmapFactory.decodeFile(getCacheFileForName(HEADER_FILE_NAME).absolutePath)
headerPreview.setImageBitmap(header)
}
setDisplayHomeAsUpEnabled(true)
setDisplayShowHomeEnabled(true)
}
avatarButton.setOnClickListener { onMediaPick(PickType.AVATAR) }
headerButton.setOnClickListener { onMediaPick(PickType.HEADER) }
avatarPreview.setOnClickListener {
avatarPreview.setImageBitmap(null)
avatarPreview.visibility = View.INVISIBLE
}
headerPreview.setOnClickListener {
headerPreview.setImageBitmap(null)
headerPreview.visibility = View.INVISIBLE
fieldList.layoutManager = LinearLayoutManager(this)
fieldList.adapter = accountFieldEditAdapter
val plusDrawable = IconicsDrawable(this, GoogleMaterial.Icon.gmd_add).sizeDp(12).color(Color.WHITE)
TextViewCompat.setCompoundDrawablesRelativeWithIntrinsicBounds(addFieldButton, plusDrawable, null, null, null)
addFieldButton.setOnClickListener {
accountFieldEditAdapter.addField()
if(accountFieldEditAdapter.itemCount >= MAX_ACCOUNT_FIELDS) {
it.isEnabled = false
}
mastodonApi.accountVerifyCredentials().enqueue(object : Callback<Account> {
override fun onResponse(call: Call<Account>, response: Response<Account>) {
if (!response.isSuccessful) {
onAccountVerifyCredentialsFailed()
return
scrollView.post{
scrollView.smoothScrollTo(0, it.bottom)
}
}
val me = response.body()
oldDisplayName = me!!.displayName
oldNote = me.source?.note
oldLocked = me.locked
displayNameEditText.setText(oldDisplayName)
noteEditText.setText(oldNote)
lockedCheckBox.isChecked = oldLocked
viewModel.obtainProfile()
if (!avatarChanged) {
Picasso.with(avatarPreview.context)
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 (!headerChanged) {
Picasso.with(headerPreview.context)
if(viewModel.headerData.value == null) {
Picasso.with(this)
.load(me.header)
.into(headerPreview)
}
}
override fun onFailure(call: Call<Account>, t: Throwable) {
onAccountVerifyCredentialsFailed()
}
}
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) {
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)
viewModel.save(displayNameEditText.text.toString(),
noteEditText.text.toString(),
lockedCheckBox.isChecked,
accountFieldEditAdapter.getFieldData(),
this)
}
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()
}
})
}
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()
}
}
}

View file

@ -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

View file

@ -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)
}

View file

@ -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
@ -9,3 +10,4 @@ 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
data class ProfileEditedEvent(val newProfileData: Account) : Dispatchable

View file

@ -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
}

View file

@ -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())

View file

@ -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);

View file

@ -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)
class Error<T> (override val data: T? = null,
val errorMessage: String? = null,
var consumed: Boolean = false
): Resource<T>(data)

View file

@ -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 {

View file

@ -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()
}
}
}

View file

@ -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,11 +48,18 @@
</RelativeLayout>
<LinearLayout
android:layout_width="@dimen/timeline_width"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:layout_marginTop="-40dp"
android:orientation="vertical">
<RelativeLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="-40dp">
android:layout_marginStart="16dp">
<com.keylesspalace.tusky.view.RoundedImageView
android:id="@+id/avatarPreview"
@ -79,7 +87,6 @@
</RelativeLayout>
<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">
@ -91,13 +98,11 @@
android:layout_marginEnd="16dp"
android:layout_marginStart="16dp"
android:hint="@string/hint_display_name"
android:importantForAutofill="no"
android:maxLength="30" />
android:importantForAutofill="no" />
</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">
@ -110,8 +115,7 @@
android:layout_marginEnd="16dp"
android:layout_marginStart="16dp"
android:hint="@string/hint_note"
android:importantForAutofill="no"
android:maxLength="160" />
android:importantForAutofill="no" />
</android.support.design.widget.TextInputLayout>
@ -136,9 +140,38 @@
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: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" />

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>