From 7f2ce91c821bd7e3ca6d0421599bccea51d7d252 Mon Sep 17 00:00:00 2001 From: onurays Date: Wed, 24 Jun 2020 09:49:58 +0300 Subject: [PATCH] Add camera and gallery options to set room avatar. --- .../roomprofile/AvatarSelectorView.kt | 216 ++++++++++++++++++ .../features/roomprofile/RoomProfileAction.kt | 2 + .../roomprofile/RoomProfileFragment.kt | 114 ++++++++- .../roomprofile/RoomProfileViewEvents.kt | 1 + .../roomprofile/RoomProfileViewModel.kt | 13 ++ .../main/res/layout/view_avatar_selector.xml | 68 ++++++ 6 files changed, 411 insertions(+), 3 deletions(-) create mode 100644 vector/src/main/java/im/vector/riotx/features/roomprofile/AvatarSelectorView.kt create mode 100644 vector/src/main/res/layout/view_avatar_selector.xml diff --git a/vector/src/main/java/im/vector/riotx/features/roomprofile/AvatarSelectorView.kt b/vector/src/main/java/im/vector/riotx/features/roomprofile/AvatarSelectorView.kt new file mode 100644 index 0000000000..6636d533f1 --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/roomprofile/AvatarSelectorView.kt @@ -0,0 +1,216 @@ +/* + * Copyright (c) 2020 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.riotx.features.roomprofile + +import android.animation.Animator +import android.animation.AnimatorListenerAdapter +import android.annotation.TargetApi +import android.content.Context +import android.graphics.drawable.BitmapDrawable +import android.os.Build +import android.util.Pair +import android.view.Gravity +import android.view.LayoutInflater +import android.view.View +import android.view.ViewAnimationUtils +import android.view.animation.Animation +import android.view.animation.AnimationSet +import android.view.animation.OvershootInterpolator +import android.view.animation.ScaleAnimation +import android.view.animation.TranslateAnimation +import android.widget.FrameLayout +import android.widget.ImageButton +import android.widget.LinearLayout +import android.widget.PopupWindow +import androidx.core.view.doOnNextLayout +import com.amulyakhare.textdrawable.TextDrawable +import com.amulyakhare.textdrawable.util.ColorGenerator +import im.vector.riotx.R +import im.vector.riotx.core.extensions.getMeasurements +import im.vector.riotx.core.utils.PERMISSIONS_FOR_TAKING_PHOTO +import im.vector.riotx.core.utils.PERMISSIONS_FOR_WRITING_FILES +import im.vector.riotx.features.roomprofile.AvatarSelectorView.Callback +import kotlin.math.max + +private const val ANIMATION_DURATION = 250 + +/** + * This class is the view presenting choices for picking avatar. + * It will return result through [Callback]. + */ +class AvatarSelectorView(context: Context, + inflater: LayoutInflater, + var callback: Callback?) + : PopupWindow(context) { + + interface Callback { + fun onTypeSelected(type: Type) + } + + private val iconColorGenerator = ColorGenerator.MATERIAL + + private var galleryButton: ImageButton + private var cameraButton: ImageButton + + private var anchor: View? = null + + init { + val root = FrameLayout(context) + val layout = inflater.inflate(R.layout.view_avatar_selector, root, true) + galleryButton = layout.findViewById(R.id.avatarGalleryButton).configure(Type.GALLERY) + cameraButton = layout.findViewById(R.id.avatarCameraButton).configure(Type.CAMERA) + contentView = root + width = LinearLayout.LayoutParams.MATCH_PARENT + height = LinearLayout.LayoutParams.WRAP_CONTENT + animationStyle = 0 + @Suppress("DEPRECATION") + setBackgroundDrawable(BitmapDrawable()) + inputMethodMode = INPUT_METHOD_NOT_NEEDED + isFocusable = true + isTouchable = true + } + + fun show(anchor: View, isKeyboardOpen: Boolean) { + this.anchor = anchor + val anchorCoordinates = IntArray(2) + anchor.getLocationOnScreen(anchorCoordinates) + if (isKeyboardOpen) { + showAtLocation(anchor, Gravity.NO_GRAVITY, 0, anchorCoordinates[1] + anchor.height) + } else { + val contentViewHeight = if (contentView.height == 0) { + contentView.getMeasurements().second + } else { + contentView.height + } + showAtLocation(anchor, Gravity.NO_GRAVITY, 0, anchorCoordinates[1] - contentViewHeight) + } + contentView.doOnNextLayout { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + animateWindowInCircular(anchor, contentView) + } else { + animateWindowInTranslate(contentView) + } + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + animateButtonIn(galleryButton, ANIMATION_DURATION / 2) + animateButtonIn(cameraButton, ANIMATION_DURATION / 2) + } + } + + override fun dismiss() { + val capturedAnchor = anchor + if (capturedAnchor != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + animateWindowOutCircular(capturedAnchor, contentView) + } else { + animateWindowOutTranslate(contentView) + } + } + + private fun animateButtonIn(button: View, delay: Int) { + val animation = AnimationSet(true) + val scale = ScaleAnimation(0.0f, 1.0f, 0.0f, 1.0f, Animation.RELATIVE_TO_SELF, 0.5f, Animation.RELATIVE_TO_SELF, 0.0f) + animation.addAnimation(scale) + animation.interpolator = OvershootInterpolator(1f) + animation.duration = ANIMATION_DURATION.toLong() + animation.startOffset = delay.toLong() + button.startAnimation(animation) + } + + @TargetApi(Build.VERSION_CODES.LOLLIPOP) + private fun animateWindowInCircular(anchor: View, contentView: View) { + val coordinates = getClickCoordinates(anchor, contentView) + val animator = ViewAnimationUtils.createCircularReveal(contentView, + coordinates.first, + coordinates.second, + 0f, + max(contentView.width, contentView.height).toFloat()) + animator.duration = ANIMATION_DURATION.toLong() + animator.start() + } + + private fun animateWindowInTranslate(contentView: View) { + val animation = TranslateAnimation(0f, 0f, contentView.height.toFloat(), 0f) + animation.duration = ANIMATION_DURATION.toLong() + getContentView().startAnimation(animation) + } + + @TargetApi(Build.VERSION_CODES.LOLLIPOP) + private fun animateWindowOutCircular(anchor: View, contentView: View) { + val coordinates = getClickCoordinates(anchor, contentView) + val animator = ViewAnimationUtils.createCircularReveal(getContentView(), + coordinates.first, + coordinates.second, + max(getContentView().width, getContentView().height).toFloat(), + 0f) + + animator.duration = ANIMATION_DURATION.toLong() + animator.addListener(object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator) { + super@AvatarSelectorView.dismiss() + } + }) + animator.start() + } + + private fun animateWindowOutTranslate(contentView: View) { + val animation = TranslateAnimation(0f, 0f, 0f, (contentView.top + contentView.height).toFloat()) + animation.duration = ANIMATION_DURATION.toLong() + animation.setAnimationListener(object : Animation.AnimationListener { + override fun onAnimationStart(animation: Animation) {} + + override fun onAnimationEnd(animation: Animation) { + super@AvatarSelectorView.dismiss() + } + + override fun onAnimationRepeat(animation: Animation) {} + }) + + getContentView().startAnimation(animation) + } + + private fun getClickCoordinates(anchor: View, contentView: View): Pair { + val anchorCoordinates = IntArray(2) + anchor.getLocationOnScreen(anchorCoordinates) + val contentCoordinates = IntArray(2) + contentView.getLocationOnScreen(contentCoordinates) + val x = anchorCoordinates[0] - contentCoordinates[0] + anchor.width / 2 + val y = anchorCoordinates[1] - contentCoordinates[1] + return Pair(x, y) + } + + private fun ImageButton.configure(type: Type): ImageButton { + this.background = TextDrawable.builder().buildRound("", iconColorGenerator.getColor(type.ordinal)) + this.setOnClickListener(TypeClickListener(type)) + return this + } + + private inner class TypeClickListener(private val type: Type) : View.OnClickListener { + + override fun onClick(v: View) { + dismiss() + callback?.onTypeSelected(type) + } + } + + /** + * The all possible types to pick with their required permissions. + */ + enum class Type(val permissionsBit: Int) { + CAMERA(PERMISSIONS_FOR_TAKING_PHOTO), + GALLERY(PERMISSIONS_FOR_WRITING_FILES), + } +} diff --git a/vector/src/main/java/im/vector/riotx/features/roomprofile/RoomProfileAction.kt b/vector/src/main/java/im/vector/riotx/features/roomprofile/RoomProfileAction.kt index 545d67c314..20498dbf84 100644 --- a/vector/src/main/java/im/vector/riotx/features/roomprofile/RoomProfileAction.kt +++ b/vector/src/main/java/im/vector/riotx/features/roomprofile/RoomProfileAction.kt @@ -17,11 +17,13 @@ package im.vector.riotx.features.roomprofile +import android.net.Uri import im.vector.matrix.android.api.session.room.notification.RoomNotificationState import im.vector.riotx.core.platform.VectorViewModelAction sealed class RoomProfileAction: VectorViewModelAction { object LeaveRoom: RoomProfileAction() data class ChangeRoomNotificationState(val notificationState: RoomNotificationState) : RoomProfileAction() + data class ChangeRoomAvatar(val uri: Uri, val fileName: String?) : RoomProfileAction() object ShareRoomProfile : RoomProfileAction() } diff --git a/vector/src/main/java/im/vector/riotx/features/roomprofile/RoomProfileFragment.kt b/vector/src/main/java/im/vector/riotx/features/roomprofile/RoomProfileFragment.kt index 52f2d95c93..19840007e6 100644 --- a/vector/src/main/java/im/vector/riotx/features/roomprofile/RoomProfileFragment.kt +++ b/vector/src/main/java/im/vector/riotx/features/roomprofile/RoomProfileFragment.kt @@ -17,15 +17,23 @@ package im.vector.riotx.features.roomprofile +import android.app.Activity +import android.content.Intent +import android.graphics.Color +import android.net.Uri import android.os.Bundle import android.os.Parcelable import android.view.MenuItem import android.view.View +import android.widget.Toast import androidx.appcompat.app.AlertDialog +import androidx.core.net.toUri import androidx.core.view.isVisible import com.airbnb.mvrx.args import com.airbnb.mvrx.fragmentViewModel import com.airbnb.mvrx.withState +import com.yalantis.ucrop.UCrop +import com.yalantis.ucrop.UCropActivity import im.vector.matrix.android.api.session.room.notification.RoomNotificationState import im.vector.matrix.android.api.util.MatrixItem import im.vector.matrix.android.api.util.toMatrixItem @@ -36,7 +44,9 @@ import im.vector.riotx.core.extensions.cleanup import im.vector.riotx.core.extensions.configureWith import im.vector.riotx.core.extensions.exhaustive import im.vector.riotx.core.extensions.setTextOrHide +import im.vector.riotx.core.intent.getFilenameFromUri import im.vector.riotx.core.platform.VectorBaseFragment +import im.vector.riotx.core.resources.ColorProvider import im.vector.riotx.core.utils.copyToClipboard import im.vector.riotx.core.utils.startSharePlainTextIntent import im.vector.riotx.features.crypto.util.toImageRes @@ -45,10 +55,13 @@ import im.vector.riotx.features.home.room.list.actions.RoomListActionsArgs import im.vector.riotx.features.home.room.list.actions.RoomListQuickActionsBottomSheet import im.vector.riotx.features.home.room.list.actions.RoomListQuickActionsSharedAction import im.vector.riotx.features.home.room.list.actions.RoomListQuickActionsSharedActionViewModel +import im.vector.riotx.multipicker.MultiPicker +import im.vector.riotx.multipicker.entity.MultiPickerImageType import kotlinx.android.parcel.Parcelize import kotlinx.android.synthetic.main.fragment_matrix_profile.* import kotlinx.android.synthetic.main.view_stub_room_profile_header.* import timber.log.Timber +import java.io.File import javax.inject.Inject @Parcelize @@ -59,8 +72,9 @@ data class RoomProfileArgs( class RoomProfileFragment @Inject constructor( private val roomProfileController: RoomProfileController, private val avatarRenderer: AvatarRenderer, - val roomProfileViewModelFactory: RoomProfileViewModel.Factory -) : VectorBaseFragment(), RoomProfileController.Callback { + val roomProfileViewModelFactory: RoomProfileViewModel.Factory, + val colorProvider: ColorProvider +) : VectorBaseFragment(), RoomProfileController.Callback, AvatarSelectorView.Callback { private val roomProfileArgs: RoomProfileArgs by args() private lateinit var roomListQuickActionsSharedActionViewModel: RoomListQuickActionsSharedActionViewModel @@ -69,6 +83,8 @@ class RoomProfileFragment @Inject constructor( private var appBarStateChangeListener: AppBarStateChangeListener? = null + private lateinit var avatarSelector: AvatarSelectorView + override fun getLayoutResId() = R.layout.fragment_matrix_profile override fun getMenuRes() = R.menu.vector_room_profile @@ -96,6 +112,7 @@ class RoomProfileFragment @Inject constructor( is RoomProfileViewEvents.Failure -> showFailure(it.throwable) is RoomProfileViewEvents.OnLeaveRoomSuccess -> onLeaveRoom() is RoomProfileViewEvents.ShareRoomProfile -> onShareRoomProfile(it.permalink) + RoomProfileViewEvents.OnChangeAvatarSuccess -> dismissLoadingDialog() }.exhaustive } roomListQuickActionsSharedActionViewModel @@ -222,6 +239,97 @@ class RoomProfileFragment @Inject constructor( } private fun onAvatarClicked(view: View, matrixItem: MatrixItem.RoomItem) { - navigator.openBigImageViewer(requireActivity(), view, matrixItem) + if (matrixItem.avatarUrl.isNullOrEmpty()) { + showAvatarSelector() + } else { + navigator.openBigImageViewer(requireActivity(), view, matrixItem) + } + } + + private fun showAvatarSelector() { + if (!::avatarSelector.isInitialized) { + avatarSelector = AvatarSelectorView(vectorBaseActivity, vectorBaseActivity.layoutInflater, this@RoomProfileFragment) + } + avatarSelector.show(vector_coordinator_layout, false) + } + + private var avatarCameraUri: Uri? = null + override fun onTypeSelected(type: AvatarSelectorView.Type) { + when (type) { + AvatarSelectorView.Type.CAMERA -> { + avatarCameraUri = MultiPicker.get(MultiPicker.CAMERA).startWithExpectingFile(this) + } + AvatarSelectorView.Type.GALLERY -> { + MultiPicker.get(MultiPicker.IMAGE).single().startWith(this) + } + } + } + + private fun onRoomAvatarSelected(image: MultiPickerImageType) { + val destinationFile = File(requireContext().cacheDir, "${image.displayName}_edited_image_${System.currentTimeMillis()}") + val uri = image.contentUri + UCrop.of(uri, destinationFile.toUri()) + .withOptions( + UCrop.Options() + .apply { + setAllowedGestures( + /* tabScale = */ UCropActivity.SCALE, + /* tabRotate = */ UCropActivity.ALL, + /* tabAspectRatio = */ UCropActivity.SCALE + ) + setToolbarTitle(image.displayName) + // Disable freestyle crop, usability was not easy + // setFreeStyleCropEnabled(true) + // Color used for toolbar icon and text + setToolbarColor(colorProvider.getColorFromAttribute(R.attr.riotx_background)) + setToolbarWidgetColor(colorProvider.getColorFromAttribute(R.attr.vctr_toolbar_primary_text_color)) + // Background + setRootViewBackgroundColor(colorProvider.getColorFromAttribute(R.attr.riotx_background)) + // Status bar color (pb in dark mode, icon of the status bar are dark) + setStatusBarColor(colorProvider.getColorFromAttribute(R.attr.riotx_header_panel_background)) + // Known issue: there is still orange color used by the lib + // https://github.com/Yalantis/uCrop/issues/602 + setActiveControlsWidgetColor(colorProvider.getColor(R.color.riotx_accent)) + // Hide the logo (does not work) + setLogoColor(Color.TRANSPARENT) + } + ) + .start(requireContext(), this) + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + if (resultCode == Activity.RESULT_OK) { + when (requestCode) { + MultiPicker.REQUEST_CODE_TAKE_PHOTO -> { + avatarCameraUri?.let { uri -> + MultiPicker.get(MultiPicker.CAMERA) + .getTakenPhoto(requireContext(), requestCode, resultCode, uri) + ?.let { + onRoomAvatarSelected(it) + } + } + } + MultiPicker.REQUEST_CODE_PICK_IMAGE -> { + MultiPicker + .get(MultiPicker.IMAGE) + .getSelectedFiles(requireContext(), requestCode, resultCode, data) + .firstOrNull()?.let { + // TODO. UCrop library cannot read from Gallery. For now, we will set avatar as it is. + // onRoomAvatarSelected(it) + onAvatarCropped(it.contentUri) + } + } + UCrop.REQUEST_CROP -> data?.let { onAvatarCropped(UCrop.getOutput(it)) } + } + } + super.onActivityResult(requestCode, resultCode, data) + } + + private fun onAvatarCropped(uri: Uri?) { + if (uri != null) { + roomProfileViewModel.handle(RoomProfileAction.ChangeRoomAvatar(uri, getFilenameFromUri(context, uri))) + } else { + Toast.makeText(requireContext(), "Cannot retrieve cropped value", Toast.LENGTH_SHORT).show() + } } } diff --git a/vector/src/main/java/im/vector/riotx/features/roomprofile/RoomProfileViewEvents.kt b/vector/src/main/java/im/vector/riotx/features/roomprofile/RoomProfileViewEvents.kt index 7a08a08126..78df127f72 100644 --- a/vector/src/main/java/im/vector/riotx/features/roomprofile/RoomProfileViewEvents.kt +++ b/vector/src/main/java/im/vector/riotx/features/roomprofile/RoomProfileViewEvents.kt @@ -26,5 +26,6 @@ sealed class RoomProfileViewEvents : VectorViewEvents { data class Failure(val throwable: Throwable) : RoomProfileViewEvents() object OnLeaveRoomSuccess : RoomProfileViewEvents() + object OnChangeAvatarSuccess : RoomProfileViewEvents() data class ShareRoomProfile(val permalink: String) : RoomProfileViewEvents() } diff --git a/vector/src/main/java/im/vector/riotx/features/roomprofile/RoomProfileViewModel.kt b/vector/src/main/java/im/vector/riotx/features/roomprofile/RoomProfileViewModel.kt index d49727d12d..5e4dde169b 100644 --- a/vector/src/main/java/im/vector/riotx/features/roomprofile/RoomProfileViewModel.kt +++ b/vector/src/main/java/im/vector/riotx/features/roomprofile/RoomProfileViewModel.kt @@ -30,6 +30,7 @@ import im.vector.matrix.rx.unwrap import im.vector.riotx.R import im.vector.riotx.core.platform.VectorViewModel import im.vector.riotx.core.resources.StringProvider +import java.util.UUID class RoomProfileViewModel @AssistedInject constructor(@Assisted private val initialState: RoomProfileViewState, private val stringProvider: StringProvider, @@ -68,6 +69,7 @@ class RoomProfileViewModel @AssistedInject constructor(@Assisted private val ini RoomProfileAction.LeaveRoom -> handleLeaveRoom() is RoomProfileAction.ChangeRoomNotificationState -> handleChangeNotificationMode(action) is RoomProfileAction.ShareRoomProfile -> handleShareRoomProfile() + is RoomProfileAction.ChangeRoomAvatar -> handleChangeAvatar(action) } private fun handleChangeNotificationMode(action: RoomProfileAction.ChangeRoomNotificationState) { @@ -96,4 +98,15 @@ class RoomProfileViewModel @AssistedInject constructor(@Assisted private val ini _viewEvents.post(RoomProfileViewEvents.ShareRoomProfile(permalink)) } } + + private fun handleChangeAvatar(action: RoomProfileAction.ChangeRoomAvatar) { + _viewEvents.post(RoomProfileViewEvents.Loading()) + room.rx().updateAvatar(action.uri, action.fileName ?: UUID.randomUUID().toString()) + .subscribe({ + _viewEvents.post(RoomProfileViewEvents.OnChangeAvatarSuccess) + }, { + _viewEvents.post(RoomProfileViewEvents.Failure(it)) + }) + .disposeOnClear() + } } diff --git a/vector/src/main/res/layout/view_avatar_selector.xml b/vector/src/main/res/layout/view_avatar_selector.xml new file mode 100644 index 0000000000..a184bd1544 --- /dev/null +++ b/vector/src/main/res/layout/view_avatar_selector.xml @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + +