Add camera and gallery options to set room avatar.

This commit is contained in:
onurays 2020-06-24 09:49:58 +03:00 committed by Benoit Marty
parent 1f30cf468a
commit 7f2ce91c82
6 changed files with 411 additions and 3 deletions

View File

@ -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<ImageButton>(R.id.avatarGalleryButton).configure(Type.GALLERY)
cameraButton = layout.findViewById<ImageButton>(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<Int, Int> {
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),
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,68 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@drawable/bg_attachment_type_selector"
android:layout_marginStart="8dp"
android:layout_marginEnd="8dp"
android:orientation="vertical"
android:paddingTop="16dp"
android:paddingBottom="16dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_margin="16dp"
android:baselineAligned="false"
android:orientation="horizontal"
android:weightSum="2">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="center"
android:orientation="vertical">
<ImageButton
android:id="@+id/avatarCameraButton"
style="@style/AttachmentTypeSelectorButton"
android:src="@drawable/ic_attachment_camera_white_24dp"
android:contentDescription="@string/attachment_type_camera"
tools:background="@color/colorAccent" />
<TextView
style="@style/AttachmentTypeSelectorLabel"
android:importantForAccessibility="no"
android:text="@string/attachment_type_camera" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="center"
android:orientation="vertical">
<ImageButton
android:id="@+id/avatarGalleryButton"
style="@style/AttachmentTypeSelectorButton"
android:src="@drawable/ic_attachment_gallery_white_24dp"
android:contentDescription="@string/attachment_type_gallery"
tools:background="@color/colorAccent" />
<TextView
style="@style/AttachmentTypeSelectorLabel"
android:importantForAccessibility="no"
android:text="@string/attachment_type_gallery" />
</LinearLayout>
</LinearLayout>
</LinearLayout>