From 00fd866cc8f2617a3297c80ba8ae05c537f01478 Mon Sep 17 00:00:00 2001 From: Valere Date: Tue, 14 May 2019 11:07:53 +0200 Subject: [PATCH] Reactions: Display existing reactions below the message - Reaction Button Bellow the message - Upgrade to constraint layout 2.0.0 beta (for Constraint Helpers / Flow) - Added tap on member name action - Cleaning --- vector/build.gradle | 2 +- .../core/utils/DebouncedClickListener.kt | 15 + .../riotredesign/core/utils/DimensionUtils.kt | 31 ++ .../home/room/detail/RoomDetailFragment.kt | 77 +++- .../timeline/TimelineEventController.kt | 1 + .../timeline/factory/MessageItemFactory.kt | 31 ++ .../detail/timeline/item/AbsMessageItem.kt | 44 ++- .../detail/timeline/item/BaseEventItem.kt | 11 +- .../timeline/item/MessageInformationData.kt | 4 +- .../features/media/ImageContentRenderer.kt | 3 + .../features/reactions/widget/CircleView.kt | 140 ++++++++ .../features/reactions/widget/DotsView.kt | 201 +++++++++++ .../reactions/widget/ReactionButton.kt | 331 ++++++++++++++++++ .../main/res/drawable/rounded_rect_shape.xml | 13 + .../res/drawable/rounded_rect_shape_off.xml | 13 + .../res/layout/item_timeline_event_base.xml | 126 ++++++- .../src/main/res/layout/reaction_button.xml | 66 ++++ vector/src/main/res/values/styles_riot.xml | 1 + vector/src/main/res/values/values.xml | 13 + 19 files changed, 1102 insertions(+), 21 deletions(-) create mode 100644 vector/src/main/java/im/vector/riotredesign/core/utils/DimensionUtils.kt create mode 100644 vector/src/main/java/im/vector/riotredesign/features/reactions/widget/CircleView.kt create mode 100644 vector/src/main/java/im/vector/riotredesign/features/reactions/widget/DotsView.kt create mode 100644 vector/src/main/java/im/vector/riotredesign/features/reactions/widget/ReactionButton.kt create mode 100644 vector/src/main/res/drawable/rounded_rect_shape.xml create mode 100644 vector/src/main/res/drawable/rounded_rect_shape_off.xml create mode 100644 vector/src/main/res/layout/reaction_button.xml create mode 100644 vector/src/main/res/values/values.xml diff --git a/vector/build.gradle b/vector/build.gradle index 3afd121b..e75bbd32 100644 --- a/vector/build.gradle +++ b/vector/build.gradle @@ -135,7 +135,7 @@ dependencies { implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines_version" implementation 'androidx.appcompat:appcompat:1.1.0-alpha03' - implementation 'androidx.constraintlayout:constraintlayout:1.1.3' + implementation 'androidx.constraintlayout:constraintlayout:2.0.0-beta1' implementation 'androidx.core:core-ktx:1.0.1' implementation 'com.jakewharton.threetenabp:threetenabp:1.1.1' diff --git a/vector/src/main/java/im/vector/riotredesign/core/utils/DebouncedClickListener.kt b/vector/src/main/java/im/vector/riotredesign/core/utils/DebouncedClickListener.kt index c2f47b92..dd16b89b 100644 --- a/vector/src/main/java/im/vector/riotredesign/core/utils/DebouncedClickListener.kt +++ b/vector/src/main/java/im/vector/riotredesign/core/utils/DebouncedClickListener.kt @@ -1,3 +1,18 @@ +/* + * Copyright 2019 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.riotredesign.core.utils import android.view.View diff --git a/vector/src/main/java/im/vector/riotredesign/core/utils/DimensionUtils.kt b/vector/src/main/java/im/vector/riotredesign/core/utils/DimensionUtils.kt new file mode 100644 index 00000000..ab3654a3 --- /dev/null +++ b/vector/src/main/java/im/vector/riotredesign/core/utils/DimensionUtils.kt @@ -0,0 +1,31 @@ +/* + * Copyright 2019 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.riotredesign.core.utils + +import android.content.Context +import android.util.TypedValue + + +object DimensionUtils { + + fun dpToPx(dp: Int, context: Context): Int { + return TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, + dp.toFloat(), + context.resources.displayMetrics + ).toInt() + } +} \ No newline at end of file diff --git a/vector/src/main/java/im/vector/riotredesign/features/home/room/detail/RoomDetailFragment.kt b/vector/src/main/java/im/vector/riotredesign/features/home/room/detail/RoomDetailFragment.kt index e6a7f902..60d1aebb 100644 --- a/vector/src/main/java/im/vector/riotredesign/features/home/room/detail/RoomDetailFragment.kt +++ b/vector/src/main/java/im/vector/riotredesign/features/home/room/detail/RoomDetailFragment.kt @@ -16,7 +16,9 @@ package im.vector.riotredesign.features.home.room.detail +import android.annotation.SuppressLint import android.app.Activity.RESULT_OK +import android.content.Context import android.content.Intent import android.graphics.Color import android.graphics.drawable.ColorDrawable @@ -25,9 +27,11 @@ import android.os.Bundle import android.os.Parcelable import android.text.Editable import android.text.Spannable +import android.text.TextUtils import android.view.HapticFeedbackConstants import android.view.LayoutInflater import android.view.View +import android.view.inputmethod.InputMethodManager import android.widget.TextView import android.widget.Toast import androidx.appcompat.app.AlertDialog @@ -52,7 +56,6 @@ import im.vector.matrix.android.api.session.Session import im.vector.matrix.android.api.session.room.model.message.* import im.vector.matrix.android.api.session.room.timeline.TimelineEvent import im.vector.matrix.android.api.session.user.model.User -import im.vector.riotredesign.features.reactions.EmojiReactionPickerActivity import im.vector.riotredesign.R import im.vector.riotredesign.core.dialogs.DialogListItem import im.vector.riotredesign.core.epoxy.LayoutManagerStateRestorer @@ -82,6 +85,7 @@ import im.vector.riotredesign.features.media.ImageContentRenderer import im.vector.riotredesign.features.media.ImageMediaViewerActivity import im.vector.riotredesign.features.media.VideoContentRenderer import im.vector.riotredesign.features.media.VideoMediaViewerActivity +import im.vector.riotredesign.features.reactions.EmojiReactionPickerActivity import kotlinx.android.parcel.Parcelize import kotlinx.android.synthetic.main.fragment_room_detail.* import org.koin.android.ext.android.inject @@ -115,6 +119,26 @@ class RoomDetailFragment : setArguments(args) } } + + /** + * Sanitize the display name. + * + * @param displayName the display name to sanitize + * @return the sanitized display name + */ + fun sanitizeDisplayname(displayName: String): String? { + var displayName = displayName + // sanity checks + if (!TextUtils.isEmpty(displayName)) { + val ircPattern = " (IRC)" + + if (displayName.endsWith(ircPattern)) { + displayName = displayName.substring(0, displayName.length - ircPattern.length) + } + } + + return displayName + } } private val session by inject() @@ -445,6 +469,11 @@ class RoomDetailFragment : override fun onAvatarClicked(informationData: MessageInformationData) { vectorBaseActivity.notImplemented() } + + @SuppressLint("SetTextI18n") + override fun onMemberNameClicked(informationData: MessageInformationData) { + insertUserDisplayNameInTextEditor(informationData.memberName?.toString()) + } // AutocompleteUserPresenter.Callback override fun onQueryUsers(query: CharSequence?) { @@ -514,4 +543,50 @@ class RoomDetailFragment : } } } + + //utils + /** + * Insert an user displayname in the message editor. + * + * @param text the text to insert. + */ + private fun insertUserDisplayNameInTextEditor(text: String?) { + if (null != text) { +// var vibrate = false + + val myDisplayName = session.getUser(session.sessionParams.credentials.userId)?.displayName + if (TextUtils.equals(myDisplayName, text)) { + // current user + if (TextUtils.isEmpty(composerEditText.text)) { + composerEditText.append(Command.EMOTE.command + " ") + composerEditText.setSelection(composerEditText.text.length) +// vibrate = true + } + } else { + // another user + if (TextUtils.isEmpty(composerEditText.text)) { + // Ensure displayName will not be interpreted as a Slash command + if (text.startsWith("/")) { + composerEditText.append("\\") + } + composerEditText.append(sanitizeDisplayname(text)!! + ": ") + } else { + composerEditText.text.insert(composerEditText.selectionStart, sanitizeDisplayname(text)!! + " ") + } + +// vibrate = true + } + +// if (vibrate && PreferencesManager.vibrateWhenMentioning(context)) { +// val v= context.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator +// if (v?.hasVibrator() == true) { +// v.vibrate(100) +// } +// } + composerEditText.requestFocus() + val imm = context?.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager + imm?.showSoftInput(composerEditText, InputMethodManager.SHOW_FORCED) + } + } + } diff --git a/vector/src/main/java/im/vector/riotredesign/features/home/room/detail/timeline/TimelineEventController.kt b/vector/src/main/java/im/vector/riotredesign/features/home/room/detail/timeline/TimelineEventController.kt index 4f480203..c40511e8 100644 --- a/vector/src/main/java/im/vector/riotredesign/features/home/room/detail/timeline/TimelineEventController.kt +++ b/vector/src/main/java/im/vector/riotredesign/features/home/room/detail/timeline/TimelineEventController.kt @@ -57,6 +57,7 @@ class TimelineEventController(private val dateFormatter: TimelineDateFormatter, fun onEventCellClicked(informationData: MessageInformationData, messageContent: MessageContent, view: View) fun onEventLongClicked(informationData: MessageInformationData, messageContent: MessageContent, view: View): Boolean fun onAvatarClicked(informationData: MessageInformationData) + fun onMemberNameClicked(informationData: MessageInformationData) } private val collapsedEventIds = linkedSetOf() diff --git a/vector/src/main/java/im/vector/riotredesign/features/home/room/detail/timeline/factory/MessageItemFactory.kt b/vector/src/main/java/im/vector/riotredesign/features/home/room/detail/timeline/factory/MessageItemFactory.kt index 43ea34f3..f8ba03de 100644 --- a/vector/src/main/java/im/vector/riotredesign/features/home/room/detail/timeline/factory/MessageItemFactory.kt +++ b/vector/src/main/java/im/vector/riotredesign/features/home/room/detail/timeline/factory/MessageItemFactory.kt @@ -81,6 +81,9 @@ class MessageItemFactory(private val colorProvider: ColorProvider, memberName = formattedMemberName, showInformation = showInformation) + //Test for reactions UX + //informationData.orderedReactionList = listOf( Triple("👍",1,false), Triple("👎",2,false)) + // val all = event.root.toContent() // val ev = all.toModel() return when (messageContent) { @@ -105,6 +108,10 @@ class MessageItemFactory(private val colorProvider: ColorProvider, DebouncedClickListener(View.OnClickListener { view -> callback?.onAvatarClicked(informationData) })) + .memberClickListener( + DebouncedClickListener(View.OnClickListener { view -> + callback?.onMemberNameClicked(informationData) + })) .cellClickListener( DebouncedClickListener(View.OnClickListener { view -> callback?.onEventCellClicked(informationData, messageContent, view) @@ -129,6 +136,10 @@ class MessageItemFactory(private val colorProvider: ColorProvider, DebouncedClickListener(View.OnClickListener { view -> callback?.onAvatarClicked(informationData) })) + .memberClickListener( + DebouncedClickListener(View.OnClickListener { view -> + callback?.onMemberNameClicked(informationData) + })) .cellClickListener( DebouncedClickListener(View.OnClickListener { view -> callback?.onEventCellClicked(informationData, messageContent, view) @@ -170,6 +181,10 @@ class MessageItemFactory(private val colorProvider: ColorProvider, DebouncedClickListener(View.OnClickListener { view -> callback?.onAvatarClicked(informationData) })) + .memberClickListener( + DebouncedClickListener(View.OnClickListener { view -> + callback?.onMemberNameClicked(informationData) + })) .clickListener( DebouncedClickListener(View.OnClickListener { view -> callback?.onImageMessageClicked(messageContent, data, view) @@ -212,6 +227,10 @@ class MessageItemFactory(private val colorProvider: ColorProvider, DebouncedClickListener(View.OnClickListener { view -> callback?.onAvatarClicked(informationData) })) + .memberClickListener( + DebouncedClickListener(View.OnClickListener { view -> + callback?.onMemberNameClicked(informationData) + })) .cellClickListener( DebouncedClickListener(View.OnClickListener { view -> callback?.onEventCellClicked(informationData, messageContent, view) @@ -239,6 +258,10 @@ class MessageItemFactory(private val colorProvider: ColorProvider, DebouncedClickListener(View.OnClickListener { view -> callback?.onAvatarClicked(informationData) })) + .memberClickListener( + DebouncedClickListener(View.OnClickListener { view -> + callback?.onMemberNameClicked(informationData) + })) //click on the text .clickListener( DebouncedClickListener(View.OnClickListener { view -> @@ -272,6 +295,10 @@ class MessageItemFactory(private val colorProvider: ColorProvider, DebouncedClickListener(View.OnClickListener { view -> callback?.onAvatarClicked(informationData) })) + .memberClickListener( + DebouncedClickListener(View.OnClickListener { view -> + callback?.onMemberNameClicked(informationData) + })) .cellClickListener( DebouncedClickListener(View.OnClickListener { view -> callback?.onEventCellClicked(informationData, messageContent, view) @@ -296,6 +323,10 @@ class MessageItemFactory(private val colorProvider: ColorProvider, DebouncedClickListener(View.OnClickListener { view -> callback?.onAvatarClicked(informationData) })) + .memberClickListener( + DebouncedClickListener(View.OnClickListener { view -> + callback?.onMemberNameClicked(informationData) + })) .cellClickListener( DebouncedClickListener(View.OnClickListener { view -> callback?.onEventCellClicked(informationData, messageContent, view) diff --git a/vector/src/main/java/im/vector/riotredesign/features/home/room/detail/timeline/item/AbsMessageItem.kt b/vector/src/main/java/im/vector/riotredesign/features/home/room/detail/timeline/item/AbsMessageItem.kt index 5ff41a01..94492e36 100644 --- a/vector/src/main/java/im/vector/riotredesign/features/home/room/detail/timeline/item/AbsMessageItem.kt +++ b/vector/src/main/java/im/vector/riotredesign/features/home/room/detail/timeline/item/AbsMessageItem.kt @@ -16,12 +16,20 @@ package im.vector.riotredesign.features.home.room.detail.timeline.item +import android.os.Build import android.view.View +import android.view.ViewGroup import android.widget.ImageView import android.widget.TextView -import im.vector.riotredesign.R +import androidx.constraintlayout.helper.widget.Flow +import androidx.core.view.children +import androidx.core.view.isGone +import androidx.core.view.isVisible import com.airbnb.epoxy.EpoxyAttribute +import im.vector.riotredesign.R +import im.vector.riotredesign.core.utils.DimensionUtils.dpToPx import im.vector.riotredesign.features.home.AvatarRenderer +import im.vector.riotredesign.features.reactions.widget.ReactionButton abstract class AbsMessageItem : BaseEventItem() { @@ -37,6 +45,9 @@ abstract class AbsMessageItem : BaseEventItem() { @EpoxyAttribute var avatarClickListener: View.OnClickListener? = null + @EpoxyAttribute + var memberClickListener: View.OnClickListener? = null + override fun bind(holder: H) { super.bind(holder) if (informationData.showInformation) { @@ -46,15 +57,17 @@ abstract class AbsMessageItem : BaseEventItem() { height = size width = size } - holder.avatarImageView.visibility = View.VISIBLE holder.avatarImageView.setOnClickListener(avatarClickListener) holder.memberNameView.visibility = View.VISIBLE + holder.memberNameView.setOnClickListener(memberClickListener) holder.timeView.visibility = View.VISIBLE holder.timeView.text = informationData.time holder.memberNameView.text = informationData.memberName AvatarRenderer.render(informationData.avatarUrl, informationData.senderId, informationData.memberName?.toString(), holder.avatarImageView) } else { + holder.avatarImageView.setOnClickListener(null) + holder.memberNameView.setOnClickListener(null) holder.avatarImageView.visibility = View.GONE holder.memberNameView.visibility = View.GONE holder.timeView.visibility = View.GONE @@ -62,6 +75,30 @@ abstract class AbsMessageItem : BaseEventItem() { holder.view.setOnClickListener(cellClickListener) holder.view.setOnLongClickListener(longClickListener) + if (informationData.orderedReactionList.isNullOrEmpty()) { + holder.reactionWrapper.isVisible = false + } else { + holder.reactionWrapper.isVisible = true + //clear all reaction buttons (but not the Flow helper!) + holder.reactionWrapper.children.forEach { (it as? ReactionButton)?.isGone = true } + val idToRefInFlow = ArrayList() + informationData.orderedReactionList?.forEachIndexed { index, reaction -> + (holder.reactionWrapper.children.elementAt(index) as? ReactionButton)?.let { reactionButton -> + reactionButton.isVisible = true + idToRefInFlow.add(reactionButton.id) + reactionButton.reactionString = reaction.first + reactionButton.reactionCount = reaction.second + reactionButton.setChecked(reaction.third) + } + } + // Just setting the view as gone will break the FlowHelper (and invisible will take too much space), + // so have to update ref ids + holder.reactionFlowHelper.referencedIds = idToRefInFlow.toIntArray() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR2 && !holder.view.isInLayout) { + holder.reactionFlowHelper.requestLayout() + } + + } } protected fun View.renderSendState() { @@ -74,6 +111,9 @@ abstract class AbsMessageItem : BaseEventItem() { val avatarImageView by bind(R.id.messageAvatarImageView) val memberNameView by bind(R.id.messageMemberNameView) val timeView by bind(R.id.messageTimeView) + + val reactionWrapper: ViewGroup by bind(R.id.messageBottomInfo) + val reactionFlowHelper: Flow by bind(R.id.reactionsFlowHelper) } } \ No newline at end of file diff --git a/vector/src/main/java/im/vector/riotredesign/features/home/room/detail/timeline/item/BaseEventItem.kt b/vector/src/main/java/im/vector/riotredesign/features/home/room/detail/timeline/item/BaseEventItem.kt index 7e6a2bc1..c72fe659 100644 --- a/vector/src/main/java/im/vector/riotredesign/features/home/room/detail/timeline/item/BaseEventItem.kt +++ b/vector/src/main/java/im/vector/riotredesign/features/home/room/detail/timeline/item/BaseEventItem.kt @@ -15,8 +15,6 @@ */ package im.vector.riotredesign.features.home.room.detail.timeline.item -import android.content.Context -import android.util.TypedValue import android.view.View import android.view.ViewStub import androidx.annotation.IdRes @@ -24,6 +22,7 @@ import androidx.constraintlayout.widget.Guideline import im.vector.riotredesign.R import im.vector.riotredesign.core.epoxy.VectorEpoxyHolder import im.vector.riotredesign.core.epoxy.VectorEpoxyModel +import im.vector.riotredesign.core.utils.DimensionUtils.dpToPx abstract class BaseEventItem : VectorEpoxyModel() { @@ -70,13 +69,5 @@ abstract class BaseEventItem : VectorEpoxyModel SMALL(30), NONE(0) } - - fun dpToPx(dp: Int, context: Context): Int { - return TypedValue.applyDimension( - TypedValue.COMPLEX_UNIT_DIP, - dp.toFloat(), - context.resources.displayMetrics - ).toInt() - } } } \ No newline at end of file diff --git a/vector/src/main/java/im/vector/riotredesign/features/home/room/detail/timeline/item/MessageInformationData.kt b/vector/src/main/java/im/vector/riotredesign/features/home/room/detail/timeline/item/MessageInformationData.kt index cf763803..b0d25f9b 100644 --- a/vector/src/main/java/im/vector/riotredesign/features/home/room/detail/timeline/item/MessageInformationData.kt +++ b/vector/src/main/java/im/vector/riotredesign/features/home/room/detail/timeline/item/MessageInformationData.kt @@ -29,5 +29,7 @@ data class MessageInformationData( val time: CharSequence? = null, val avatarUrl: String?, val memberName: CharSequence? = null, - val showInformation: Boolean = true + val showInformation: Boolean = true, + /*List of reactions (emoji,count,isSelected)*/ + var orderedReactionList: List>? = null ) : Parcelable \ No newline at end of file diff --git a/vector/src/main/java/im/vector/riotredesign/features/media/ImageContentRenderer.kt b/vector/src/main/java/im/vector/riotredesign/features/media/ImageContentRenderer.kt index 42c50e68..fcd7cab1 100644 --- a/vector/src/main/java/im/vector/riotredesign/features/media/ImageContentRenderer.kt +++ b/vector/src/main/java/im/vector/riotredesign/features/media/ImageContentRenderer.kt @@ -20,10 +20,12 @@ import android.media.ExifInterface import android.net.Uri import android.os.Parcelable import android.widget.ImageView +import com.bumptech.glide.load.resource.bitmap.RoundedCorners import com.github.piasy.biv.view.BigImageView import im.vector.matrix.android.api.Matrix import im.vector.matrix.android.api.session.content.ContentUrlResolver import im.vector.riotredesign.core.glide.GlideApp +import im.vector.riotredesign.core.utils.DimensionUtils.dpToPx import kotlinx.android.parcel.Parcelize import java.io.File @@ -67,6 +69,7 @@ object ImageContentRenderer { .with(imageView) .load(resolvedUrl) .dontAnimate() + .transform(RoundedCorners(dpToPx(8,imageView.context))) .thumbnail(0.3f) .into(imageView) } diff --git a/vector/src/main/java/im/vector/riotredesign/features/reactions/widget/CircleView.kt b/vector/src/main/java/im/vector/riotredesign/features/reactions/widget/CircleView.kt new file mode 100644 index 00000000..119f340c --- /dev/null +++ b/vector/src/main/java/im/vector/riotredesign/features/reactions/widget/CircleView.kt @@ -0,0 +1,140 @@ +/* + * Copyright 2019 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.riotredesign.features.reactions.widget + +import android.animation.ArgbEvaluator +import android.content.Context +import android.graphics.* +import android.util.AttributeSet +import android.util.Property +import android.view.View + + +/** + * This view is responsible for drawing big circle that will pulse when clicked + * As describe in http://frogermcs.github.io/twitters-like-animation-in-android-alternative/ + */ +class CircleView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, + defStyleAttr: Int = 0) : View(context, attrs, defStyleAttr) { + + var startColor = -0xa8de + var endColor = -0x3ef9 + + private val argbEvaluator = ArgbEvaluator() + + private val circlePaint = Paint() + private val maskPaint = Paint() + + private var tempBitmap: Bitmap? = null + private var tempCanvas: Canvas? = null + + var outerCircleRadiusProgress = 0f + set(value) { + field = value + updateCircleColor() + postInvalidate() + } + var innerCircleRadiusProgress = 0f + set(value) { + field = value + postInvalidate() + } + + private var maxCircleSize: Int = 0 + + init { + circlePaint.style = Paint.Style.FILL + maskPaint.xfermode = PorterDuffXfermode(PorterDuff.Mode.CLEAR) + } + + override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { + super.onSizeChanged(w, h, oldw, oldh) + maxCircleSize = w / 2 + tempBitmap = Bitmap.createBitmap(width, width, Bitmap.Config.ARGB_8888) + tempCanvas = Canvas(tempBitmap) + } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + tempCanvas!!.drawColor(0xffffff, PorterDuff.Mode.CLEAR) + tempCanvas!!.drawCircle(width / 2f, height / 2f, outerCircleRadiusProgress * maxCircleSize, circlePaint) + tempCanvas!!.drawCircle(width / 2f, height / 2f, innerCircleRadiusProgress * maxCircleSize, maskPaint) + canvas.drawBitmap(tempBitmap, 0f, 0f, null) + } + +// fun setInnerCircleRadiusProgress(innerCircleRadiusProgress: Float) { +// this.innerCircleRadiusProgress = innerCircleRadiusProgress +// postInvalidate() +// } + +// fun getInnerCircleRadiusProgress(): Float { +// return innerCircleRadiusProgress +// } + +// fun setOuterCircleRadiusProgress(outerCircleRadiusProgress: Float) { +// this.outerCircleRadiusProgress = outerCircleRadiusProgress +// updateCircleColor() +// postInvalidate() +// } + + private fun updateCircleColor() { + var colorProgress = clamp(outerCircleRadiusProgress, 0.5f, 1f) as Float + colorProgress = mapValueFromRangeToRange(colorProgress, 0.5f, 1f, 0f, 1f) + this.circlePaint.color = argbEvaluator.evaluate(colorProgress, startColor, endColor) as Int + } + +// fun getOuterCircleRadiusProgress(): Float { +// return outerCircleRadiusProgress +// } + + + companion object { + + val INNER_CIRCLE_RADIUS_PROGRESS: Property = object : Property(Float::class.java, "innerCircleRadiusProgress") { + override operator fun get(`object`: CircleView): Float? { + return `object`.innerCircleRadiusProgress + } + + override operator fun set(`object`: CircleView, value: Float?) { + value?.let { + `object`.innerCircleRadiusProgress = it + } + } + } + + val OUTER_CIRCLE_RADIUS_PROGRESS: Property = object : Property(Float::class.java, "outerCircleRadiusProgress") { + override operator fun get(`object`: CircleView): Float? { + return `object`.outerCircleRadiusProgress + } + + override operator fun set(`object`: CircleView, value: Float?) { + value?.let { + `object`.outerCircleRadiusProgress = it + } + + } + } + + + fun mapValueFromRangeToRange(value: Float, fromLow: Float, fromHigh: Float, toLow: Float, toHigh: Float): Float { + return toLow + (value - fromLow) / (fromHigh - fromLow) * (toHigh - toLow) + } + + fun clamp(value: Float, low: Float, high: Float): Float { + return Math.min(Math.max(value, low), high) + } + } +} \ No newline at end of file diff --git a/vector/src/main/java/im/vector/riotredesign/features/reactions/widget/DotsView.kt b/vector/src/main/java/im/vector/riotredesign/features/reactions/widget/DotsView.kt new file mode 100644 index 00000000..d0e8fac1 --- /dev/null +++ b/vector/src/main/java/im/vector/riotredesign/features/reactions/widget/DotsView.kt @@ -0,0 +1,201 @@ +/* + * Copyright 2019 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.riotredesign.features.reactions.widget + +import android.animation.ArgbEvaluator +import android.content.Context +import android.graphics.Canvas +import android.graphics.Paint +import android.util.AttributeSet +import android.util.Property +import android.view.View + +/** + * This view will draw dots floating around the center of it's view + * As describe in http://frogermcs.github.io/twitters-like-animation-in-android-alternative/ + */ +class DotsView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, + defStyleAttr: Int = 0) : View(context, attrs, defStyleAttr) { + + private var COLOR_1 = -0x3ef9 + private var COLOR_2 = -0x6800 + private var COLOR_3 = -0xa8de + private var COLOR_4 = -0xbbcca + + private val circlePaints = arrayOfNulls(4) + + private var centerX: Int = 0 + private var centerY: Int = 0 + + private var maxOuterDotsRadius: Float = 0.toFloat() + private var maxInnerDotsRadius: Float = 0.toFloat() + private var maxDotSize: Float = 0.toFloat() + + var currentProgress = 0f + set(value) { + field = value + updateInnerDotsPosition() + updateOuterDotsPosition() + updateDotsPaints() + updateDotsAlpha() + + postInvalidate() + } + + private var currentRadius1 = 0f + private var currentDotSize1 = 0f + + private var currentDotSize2 = 0f + private var currentRadius2 = 0f + + private val argbEvaluator = ArgbEvaluator() + + init { + for (i in circlePaints.indices) { + circlePaints[i] = Paint() + circlePaints[i]!!.style = Paint.Style.FILL + } + } + + override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { + super.onSizeChanged(w, h, oldw, oldh) + centerX = w / 2 + centerY = h / 2 + maxDotSize = 3f + maxOuterDotsRadius = w / 2 - maxDotSize * 2 + maxInnerDotsRadius = 0.8f * maxOuterDotsRadius + } + + override fun onDraw(canvas: Canvas) { + drawOuterDotsFrame(canvas) + drawInnerDotsFrame(canvas) + } + + private fun drawOuterDotsFrame(canvas: Canvas) { + for (i in 0 until DOTS_COUNT) { + val cX = (centerX + currentRadius1 * Math.cos(i.toDouble() * OUTER_DOTS_POSITION_ANGLE.toDouble() * Math.PI / 180)).toFloat() + val cY = (centerY + currentRadius1 * Math.sin(i.toDouble() * OUTER_DOTS_POSITION_ANGLE.toDouble() * Math.PI / 180)).toFloat() + canvas.drawCircle(cX, cY, currentDotSize1, circlePaints[i % circlePaints.size]) + } + } + + private fun drawInnerDotsFrame(canvas: Canvas) { + for (i in 0 until DOTS_COUNT) { + val cX = (centerX + currentRadius2 * Math.cos((i * OUTER_DOTS_POSITION_ANGLE - 10) * Math.PI / 180)).toFloat() + val cY = (centerY + currentRadius2 * Math.sin((i * OUTER_DOTS_POSITION_ANGLE - 10) * Math.PI / 180)).toFloat() + canvas.drawCircle(cX, cY, currentDotSize2, circlePaints[(i + 1) % circlePaints.size]) + } + } + +// fun setCurrentProgress(currentProgress: Float) { +// this.currentProgress = currentProgress +// +// updateInnerDotsPosition() +// updateOuterDotsPosition() +// updateDotsPaints() +// updateDotsAlpha() +// +// postInvalidate() +// } +// +// fun getCurrentProgress(): Float { +// return currentProgress +// } + + private fun updateInnerDotsPosition() { + if (currentProgress < 0.3f) { + this.currentRadius2 = CircleView.mapValueFromRangeToRange(currentProgress, 0f, 0.3f, 0f, maxInnerDotsRadius) + } else { + this.currentRadius2 = maxInnerDotsRadius + } + + if (currentProgress < 0.2) { + this.currentDotSize2 = maxDotSize + } else if (currentProgress < 0.5) { + this.currentDotSize2 = CircleView.mapValueFromRangeToRange( + currentProgress, 0.2f, 0.5f, maxDotSize, 0.3f * maxDotSize) + } else { + this.currentDotSize2 = CircleView.mapValueFromRangeToRange( + currentProgress, 0.5f, 1f, maxDotSize * 0.3f, 0f) + } + + } + + + fun setColors(primary: Int, secondary: Int) { + COLOR_1 = primary + COLOR_2 = secondary + COLOR_3 = primary + COLOR_4 = secondary + } + + private fun updateOuterDotsPosition() { + if (currentProgress < 0.3f) { + this.currentRadius1 = CircleView.mapValueFromRangeToRange( + currentProgress, 0.0f, 0.3f, 0f, maxOuterDotsRadius * 0.8f) + } else { + this.currentRadius1 = CircleView.mapValueFromRangeToRange( + currentProgress, 0.3f, 1f, 0.8f * maxOuterDotsRadius, maxOuterDotsRadius) + } + + if (currentProgress < 0.7) { + this.currentDotSize1 = maxDotSize + } else { + this.currentDotSize1 = CircleView.mapValueFromRangeToRange( + currentProgress, 0.7f, 1f, maxDotSize, 0f) + } + } + + private fun updateDotsPaints() { + if (currentProgress < 0.5f) { + val progress = CircleView.mapValueFromRangeToRange(currentProgress, 0f, 0.5f, 0f, 1f) as Float + circlePaints[0]?.color = argbEvaluator.evaluate(progress, COLOR_1, COLOR_2) as Int + circlePaints[1]?.color = argbEvaluator.evaluate(progress, COLOR_2, COLOR_3) as Int + circlePaints[2]?.color = argbEvaluator.evaluate(progress, COLOR_3, COLOR_4) as Int + circlePaints[3]?.color = argbEvaluator.evaluate(progress, COLOR_4, COLOR_1) as Int + } else { + val progress = CircleView.mapValueFromRangeToRange(currentProgress, 0.5f, 1f, 0f, 1f) as Float + circlePaints[0]?.color = argbEvaluator.evaluate(progress, COLOR_2, COLOR_3) as Int + circlePaints[1]?.color = argbEvaluator.evaluate(progress, COLOR_3, COLOR_4) as Int + circlePaints[2]?.color = argbEvaluator.evaluate(progress, COLOR_4, COLOR_1) as Int + circlePaints[3]?.color = argbEvaluator.evaluate(progress, COLOR_1, COLOR_2) as Int + } + } + + private fun updateDotsAlpha() { + val progress = CircleView.clamp(currentProgress, 0.6f, 1f) as Float + val alpha = (CircleView.mapValueFromRangeToRange(progress, 0.6f, 1f, 255f, 0f) as? Float)?.toInt() + ?: 0 + circlePaints.forEach { it?.alpha = alpha } + } + + + companion object { + + private val DOTS_COUNT = 7 + private val OUTER_DOTS_POSITION_ANGLE = 360 / DOTS_COUNT + + val DOTS_PROGRESS: Property = object : Property(Float::class.java, "dotsProgress") { + override operator fun get(`object`: DotsView): Float? { + return `object`.currentProgress + } + + override operator fun set(`object`: DotsView, value: Float?) { + `object`.currentProgress = value!! + } + } + } +} \ No newline at end of file diff --git a/vector/src/main/java/im/vector/riotredesign/features/reactions/widget/ReactionButton.kt b/vector/src/main/java/im/vector/riotredesign/features/reactions/widget/ReactionButton.kt new file mode 100644 index 00000000..c4a87d0f --- /dev/null +++ b/vector/src/main/java/im/vector/riotredesign/features/reactions/widget/ReactionButton.kt @@ -0,0 +1,331 @@ +/* + * Copyright 2019 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.riotredesign.features.reactions.widget + +import android.animation.Animator +import android.animation.AnimatorListenerAdapter +import android.animation.AnimatorSet +import android.animation.ObjectAnimator +import android.content.Context +import android.content.res.TypedArray +import android.graphics.drawable.Drawable +import android.util.AttributeSet +import android.view.LayoutInflater +import android.view.MotionEvent +import android.view.View +import android.view.animation.AccelerateDecelerateInterpolator +import android.view.animation.DecelerateInterpolator +import android.view.animation.OvershootInterpolator +import android.widget.FrameLayout +import android.widget.ImageView +import android.widget.TextView +import androidx.annotation.ColorInt +import androidx.annotation.ColorRes +import androidx.core.content.ContextCompat +import im.vector.riotredesign.R + +/** + * An animated reaction button. + * Displays a String reaction (emoji), with a count, and that can be selected or not (toggle) + */ +class ReactionButton @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, + defStyleAttr: Int = 0) : FrameLayout(context, attrs, defStyleAttr), View.OnClickListener { + + companion object { + private val DECCELERATE_INTERPOLATOR = DecelerateInterpolator() + private val ACCELERATE_DECELERATE_INTERPOLATOR = AccelerateDecelerateInterpolator() + private val OVERSHOOT_INTERPOLATOR = OvershootInterpolator(4f) + + } + + private var emojiView: TextView? = null + private var countTextView: TextView? = null + + private var reactionSelector: View? = null + + + private var dotsView: DotsView + private var circleView: CircleView + var reactedListener: ReactedListener? = null + private var dotPrimaryColor: Int = 0 + private var dotSecondaryColor: Int = 0 + private var circleStartColor: Int = 0 + private var circleEndColor: Int = 0 + + var reactionCount = 11 + set(value) { + field = value + countTextView?.text = value.toString() + } + + + var reactionString = "😀" + set(value) { + field = value + emojiView?.text = field + } + + private var animationScaleFactor: Float = 0.toFloat() + + private var isChecked: Boolean = false + + private var animatorSet: AnimatorSet? = null + + private var onDrawable: Drawable? = null + private var offDrawable: Drawable? = null + + init { + LayoutInflater.from(getContext()).inflate(R.layout.reaction_button, this, true) + emojiView = findViewById(R.id.reactionText) + dotsView = findViewById(R.id.dots) + circleView = findViewById(R.id.circle) + reactionSelector = findViewById(R.id.reactionSelector) + countTextView = findViewById(R.id.reactionCount) + + countTextView?.text = reactionCount.toString() + + val array = context.obtainStyledAttributes(attrs, R.styleable.ReactionButton, defStyleAttr, 0) + + onDrawable = ContextCompat.getDrawable(context, R.drawable.rounded_rect_shape) + offDrawable = ContextCompat.getDrawable(context, R.drawable.rounded_rect_shape_off) + + circleStartColor = array.getColor(R.styleable.ReactionButton_circle_start_color, 0) + + if (circleStartColor != 0) + circleView.startColor = circleStartColor + + circleEndColor = array.getColor(R.styleable.ReactionButton_circle_end_color, 0) + + if (circleEndColor != 0) + circleView.endColor = circleEndColor + + dotPrimaryColor = array.getColor(R.styleable.ReactionButton_dots_primary_color, 0) + dotSecondaryColor = array.getColor(R.styleable.ReactionButton_dots_secondary_color, 0) + + if (dotPrimaryColor != 0 && dotSecondaryColor != 0) { + dotsView.setColors(dotPrimaryColor, dotSecondaryColor) + } + + array.getString(R.styleable.ReactionButton_emoji)?.let { + reactionString = it + } + + reactionCount = array.getInt(R.styleable.ReactionButton_reaction_count, 0) + + val status = array.getBoolean(R.styleable.ReactionButton_toggled, false) + setChecked(status) + setOnClickListener(this) + array.recycle() + } + + private fun getDrawableFromResource(array: TypedArray, styleableIndexId: Int): Drawable? { + val id = array.getResourceId(styleableIndexId, -1) + + return if (-1 != id) ContextCompat.getDrawable(context, id) else null + } + + /** + * This triggers the entire functionality of the button such as icon changes, + * animations, listeners etc. + * + * @param v + */ + override fun onClick(v: View) { + + if (!isEnabled) + return + + isChecked = !isChecked + + //icon!!.setImageDrawable(if (isChecked) likeDrawable else unLikeDrawable) + reactionSelector?.background = if (isChecked) onDrawable else offDrawable + + if (isChecked) { + reactedListener?.onReacted(this) + } else { + reactedListener?.onUnReacted(this) + } + + + if (animatorSet != null) { + animatorSet!!.cancel() + } + + if (isChecked) { + emojiView!!.animate().cancel() + emojiView!!.scaleX = 0f + emojiView!!.scaleY = 0f + + circleView.innerCircleRadiusProgress = 0f + circleView.outerCircleRadiusProgress = 0f + dotsView.currentProgress = 0f + + animatorSet = AnimatorSet() + + val outerCircleAnimator = ObjectAnimator.ofFloat(circleView, CircleView.OUTER_CIRCLE_RADIUS_PROGRESS, 0.1f, 1f) + outerCircleAnimator.duration = 250 + outerCircleAnimator.interpolator = DECCELERATE_INTERPOLATOR + + val innerCircleAnimator = ObjectAnimator.ofFloat(circleView, CircleView.INNER_CIRCLE_RADIUS_PROGRESS, 0.1f, 1f) + innerCircleAnimator.duration = 200 + innerCircleAnimator.startDelay = 200 + innerCircleAnimator.interpolator = DECCELERATE_INTERPOLATOR + + val starScaleYAnimator = ObjectAnimator.ofFloat(emojiView, ImageView.SCALE_Y, 0.2f, 1f) + starScaleYAnimator.duration = 350 + starScaleYAnimator.startDelay = 250 + starScaleYAnimator.interpolator = OVERSHOOT_INTERPOLATOR + + val starScaleXAnimator = ObjectAnimator.ofFloat(emojiView, ImageView.SCALE_X, 0.2f, 1f) + starScaleXAnimator.duration = 350 + starScaleXAnimator.startDelay = 250 + starScaleXAnimator.interpolator = OVERSHOOT_INTERPOLATOR + + val dotsAnimator = ObjectAnimator.ofFloat(dotsView, DotsView.DOTS_PROGRESS, 0f, 1f)//.ofFloat(dotsView, DotsView.DOTS_PROGRESS, 0, 1f) + dotsAnimator.duration = 900 + dotsAnimator.startDelay = 50 + dotsAnimator.interpolator = ACCELERATE_DECELERATE_INTERPOLATOR + + animatorSet!!.playTogether( + outerCircleAnimator, + innerCircleAnimator, + starScaleYAnimator, + starScaleXAnimator, + dotsAnimator + ) + + animatorSet!!.addListener(object : AnimatorListenerAdapter() { + override fun onAnimationCancel(animation: Animator) { + circleView.innerCircleRadiusProgress = 0f + circleView.outerCircleRadiusProgress = 0f + dotsView.currentProgress = 0f + emojiView!!.scaleX = 1f + emojiView!!.scaleY = 1f + } + + override fun onAnimationEnd(animation: Animator) { +// if (animationEndListener != null) { +// // animationEndListener!!.onAnimationEnd(this@ReactionButton) +// } + } + }) + + animatorSet!!.start() + } + } + + /** + * Used to trigger the scale animation that takes places on the + * icon when the button is touched. + * + * @param event + * @return + */ + override fun onTouchEvent(event: MotionEvent): Boolean { + if (!isEnabled) + return true + + when (event.action) { + MotionEvent.ACTION_DOWN -> + /* + Commented out this line and moved the animation effect to the action up event due to + conflicts that were occurring when library is used in sliding type views. + + icon.animate().scaleX(0.7f).scaleY(0.7f).setDuration(150).setInterpolator(DECCELERATE_INTERPOLATOR); + */ + isPressed = true + + MotionEvent.ACTION_MOVE -> { + val x = event.x + val y = event.y + val isInside = x > 0 && x < width && y > 0 && y < height + if (isPressed != isInside) { + isPressed = isInside + } + } + + MotionEvent.ACTION_UP -> { + emojiView!!.animate().scaleX(0.7f).scaleY(0.7f).setDuration(150).interpolator = DECCELERATE_INTERPOLATOR + emojiView!!.animate().scaleX(1f).scaleY(1f).interpolator = DECCELERATE_INTERPOLATOR + if (isPressed) { + performClick() + isPressed = false + } + } + MotionEvent.ACTION_CANCEL -> isPressed = false + } + return true + } + + /** + * This set sets the colours that are used for the little dots + * that will be exploding once the like button is clicked. + * + * @param primaryColor + * @param secondaryColor + */ + fun setExplodingDotColorsRes(@ColorRes primaryColor: Int, @ColorRes secondaryColor: Int) { + dotsView.setColors(ContextCompat.getColor(context, primaryColor), ContextCompat.getColor(context, secondaryColor)) + } + + fun setExplodingDotColorsInt(@ColorInt primaryColor: Int, @ColorInt secondaryColor: Int) { + dotsView.setColors(primaryColor, secondaryColor) + } + + fun setCircleStartColorRes(@ColorRes circleStartColor: Int) { + this.circleStartColor = ContextCompat.getColor(context, circleStartColor) + circleView.startColor = this.circleStartColor + } + + fun setCircleStartColorInt(@ColorInt circleStartColor: Int) { + this.circleStartColor = circleStartColor + circleView.startColor = circleStartColor + } + + fun setCircleEndColorRes(@ColorRes circleEndColor: Int) { + this.circleEndColor = ContextCompat.getColor(context, circleEndColor) + circleView.endColor = this.circleEndColor + } + + /** + * Sets the initial state of the button to liked + * or unliked. + * + * @param status + */ + fun setChecked(status: Boolean?) { + if (status!!) { + isChecked = true + reactionSelector?.background = onDrawable + } else { + isChecked = false + reactionSelector?.background = offDrawable + } + } + + /** + * Sets the factor by which the dots should be sized. + */ + fun setAnimationScaleFactor(animationScaleFactor: Float) { + this.animationScaleFactor = animationScaleFactor + } + + + interface ReactedListener { + fun onReacted(reactionButton: ReactionButton) + fun onUnReacted(reactionButton: ReactionButton) + } +} \ No newline at end of file diff --git a/vector/src/main/res/drawable/rounded_rect_shape.xml b/vector/src/main/res/drawable/rounded_rect_shape.xml new file mode 100644 index 00000000..dbb12374 --- /dev/null +++ b/vector/src/main/res/drawable/rounded_rect_shape.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/vector/src/main/res/drawable/rounded_rect_shape_off.xml b/vector/src/main/res/drawable/rounded_rect_shape_off.xml new file mode 100644 index 00000000..e72c7383 --- /dev/null +++ b/vector/src/main/res/drawable/rounded_rect_shape_off.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/vector/src/main/res/layout/item_timeline_event_base.xml b/vector/src/main/res/layout/item_timeline_event_base.xml index 80a0159e..89c3b318 100644 --- a/vector/src/main/res/layout/item_timeline_event_base.xml +++ b/vector/src/main/res/layout/item_timeline_event_base.xml @@ -9,7 +9,6 @@ android:paddingLeft="8dp" android:paddingRight="8dp"> - @@ -48,14 +49,15 @@ @@ -80,4 +82,116 @@ tools:ignore="MissingConstraints" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/vector/src/main/res/layout/reaction_button.xml b/vector/src/main/res/layout/reaction_button.xml new file mode 100644 index 00000000..8d66d987 --- /dev/null +++ b/vector/src/main/res/layout/reaction_button.xml @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + diff --git a/vector/src/main/res/values/styles_riot.xml b/vector/src/main/res/values/styles_riot.xml index b04744d5..7ab2caa1 100644 --- a/vector/src/main/res/values/styles_riot.xml +++ b/vector/src/main/res/values/styles_riot.xml @@ -266,5 +266,6 @@ \ No newline at end of file diff --git a/vector/src/main/res/values/values.xml b/vector/src/main/res/values/values.xml new file mode 100644 index 00000000..55543640 --- /dev/null +++ b/vector/src/main/res/values/values.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + +