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>