diff --git a/app/src/main/java/com/nextcloud/utils/text/Spans.kt b/app/src/main/java/com/nextcloud/utils/text/Spans.kt new file mode 100644 index 000000000000..9ca42fe8a74e --- /dev/null +++ b/app/src/main/java/com/nextcloud/utils/text/Spans.kt @@ -0,0 +1,51 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2021 Andy Scherzinger + * SPDX-FileCopyrightText: 2017-2018 Mario Danic + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.utils.text + +import android.graphics.drawable.Drawable +import thirdparties.fresco.BetterImageSpan + +class Spans { + class MentionChipSpan(drawable: Drawable, verticalAlignment: Int, var id: String, var label: CharSequence?) : + BetterImageSpan(drawable, verticalAlignment) { + override fun equals(o: Any?): Boolean { + if (o === this) { + return true + } + if (o !is MentionChipSpan) { + return false + } + val other = o + if (!other.canEqual(this as Any)) { + return false + } + val thisId: Any? = this.id + val otherId: Any? = other.id + if (if (thisId == null) otherId != null else (thisId != otherId)) { + return false + } + val thisLabel: Any? = this.label + val otherLabel: Any? = other.label + + return if (thisLabel == null) otherLabel == null else (thisLabel == otherLabel) + } + + protected fun canEqual(other: Any?): Boolean = other is MentionChipSpan + + override fun hashCode(): Int { + val prime = 59 + var result = 1 + val thisId: Any? = this.id + result = result * prime + (if (thisId == null) 43 else thisId.hashCode()) + val label: Any? = this.label + return result * prime + (if (label == null) 43 else label.hashCode()) + } + + override fun toString(): String = "Spans.MentionChipSpan(id=" + this.id + ", label=" + this.label + ")" + } +} diff --git a/app/src/main/java/com/owncloud/android/ui/activities/adapter/ActivityListAdapter.kt b/app/src/main/java/com/owncloud/android/ui/activities/adapter/ActivityListAdapter.kt index 2ae062e338f4..d3b1fa145597 100644 --- a/app/src/main/java/com/owncloud/android/ui/activities/adapter/ActivityListAdapter.kt +++ b/app/src/main/java/com/owncloud/android/ui/activities/adapter/ActivityListAdapter.kt @@ -8,12 +8,13 @@ package com.owncloud.android.ui.activities.adapter import android.content.Context import android.graphics.Typeface +import android.graphics.drawable.Drawable import android.text.Spannable import android.text.SpannableStringBuilder import android.text.TextPaint +import android.text.TextUtils import android.text.format.DateFormat import android.text.format.DateUtils -import android.text.method.LinkMovementMethod import android.text.style.ClickableSpan import android.text.style.ForegroundColorSpan import android.text.style.StyleSpan @@ -27,10 +28,12 @@ import androidx.annotation.DrawableRes import androidx.fragment.app.FragmentActivity import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.chip.ChipDrawable import com.nextcloud.android.common.ui.theme.utils.ColorRole import com.nextcloud.client.account.CurrentAccountProvider import com.nextcloud.common.NextcloudClient import com.nextcloud.utils.GlideHelper +import com.nextcloud.utils.text.Spans.MentionChipSpan import com.owncloud.android.MainApp import com.owncloud.android.R import com.owncloud.android.databinding.ActivityListItemBinding @@ -49,6 +52,7 @@ import com.owncloud.android.utils.theme.ViewThemeUtils import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import thirdparties.fresco.BetterImageSpan import java.util.Locale import kotlin.math.floor import kotlin.math.log @@ -62,7 +66,8 @@ open class ActivityListAdapter( private val isDetailView: Boolean, private val viewThemeUtils: ViewThemeUtils ) : RecyclerView.Adapter(), - StickyHeaderAdapter { + StickyHeaderAdapter, + DisplayUtils.AvatarGenerationListener { protected var client: NextcloudClient? = null val values: MutableList = mutableListOf() @@ -113,9 +118,10 @@ open class ActivityListAdapter( when { activity.richSubjectElement.richSubject.isNotEmpty() -> holder.binding.subject.apply { - visibility = View.VISIBLE - movementMethod = LinkMovementMethod.getInstance() - setText(addClickablePart(activity.richSubjectElement), TextView.BufferType.SPANNABLE) + text = addClickablePart(activity.richSubjectElement) + // visibility = View.VISIBLE + // movementMethod = LinkMovementMethod.getInstance() + // setText(addClickablePart(activity.richSubjectElement), TextView.BufferType.SPANNABLE) } activity.subject.isNotEmpty() -> holder.binding.subject.apply { @@ -176,6 +182,19 @@ open class ActivityListAdapter( } } + fun getDrawableForMentionChipSpan(chipResource: Int, text: String): ChipDrawable { + val chip = ChipDrawable.createFromResource(context, chipResource).apply { + setEllipsize(TextUtils.TruncateAt.MIDDLE) + setLayoutDirection(context.getResources().getConfiguration().getLayoutDirection()) + setText(text) + setChipIconResource(R.drawable.accent_circle) + } + + chip.setBounds(0, 0, chip.getIntrinsicWidth(), chip.getIntrinsicHeight()) + + return chip + } + private suspend fun nextcloudClient(): NextcloudClient = withContext(Dispatchers.IO) { OwnCloudClientManagerFactory.getDefaultSingleton() .getNextcloudClientFor(currentAccountProvider.user.toOwnCloudAccount(), context) @@ -224,6 +243,7 @@ open class ActivityListAdapter( return imageView } + @Suppress("NestedBlockDepth") private fun addClickablePart(richElement: RichElement): SpannableStringBuilder { var text = richElement.richSubject val ssb = SpannableStringBuilder(text) @@ -236,29 +256,57 @@ open class ActivityListAdapter( } if (richObject != null) { - val name = richObject.name.orEmpty() - ssb.replace(idx1, idx2, name) - text = ssb.toString() - idx2 = idx1 + name.length - - ssb.setSpan( - object : ClickableSpan() { - override fun onClick(widget: View) = activityListInterface.onActivityClicked(richObject) - override fun updateDrawState(ds: TextPaint) { - ds.isUnderlineText = false - } - }, - idx1, - idx2, - 0 - ) - ssb.setSpan(StyleSpan(Typeface.BOLD), idx1, idx2, 0) - ssb.setSpan( - ForegroundColorSpan(context.resources.getColor(R.color.text_color)), - idx1, - idx2, - Spannable.SPAN_EXCLUSIVE_EXCLUSIVE - ) + if ("user".equals(richObject.type)) { + val name = richObject.name + + val drawableForChip = getDrawableForMentionChipSpan(R.xml.chip_others, name ?: "") + + val mentionChipSpan = MentionChipSpan( + drawableForChip, + BetterImageSpan.ALIGN_CENTER, + richObject.id ?: "", + name + ) + + if (richObject.id != null) { + DisplayUtils.setAvatar( + currentAccountProvider.user, + richObject.id!!, + name, + this, + context.resources.getDimension(R.dimen.avatar_icon_radius), + context.resources, + drawableForChip, + context + ) + } + + ssb.setSpan(mentionChipSpan, idx1, idx2, Spannable.SPAN_INCLUSIVE_EXCLUSIVE) + } else { + val name = richObject.name.orEmpty() + ssb.replace(idx1, idx2, name) + text = ssb.toString() + idx2 = idx1 + name.length + + ssb.setSpan( + object : ClickableSpan() { + override fun onClick(widget: View) = activityListInterface.onActivityClicked(richObject) + override fun updateDrawState(ds: TextPaint) { + ds.isUnderlineText = false + } + }, + idx1, + idx2, + 0 + ) + ssb.setSpan(StyleSpan(Typeface.BOLD), idx1, idx2, 0) + ssb.setSpan( + ForegroundColorSpan(context.resources.getColor(R.color.text_color)), + idx1, + idx2, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE + ) + } } idx1 = text.indexOf('{', idx2) } @@ -308,6 +356,12 @@ open class ActivityListAdapter( override fun isHeader(itemPosition: Int) = itemPosition in values.indices && getItemViewType(itemPosition) == HEADER_TYPE + override fun avatarGenerated(avatarDrawable: Drawable, callContext: Any) { + (callContext as ChipDrawable).chipIcon = avatarDrawable + } + + override fun shouldCallGeneratedCallback(tag: String, callContext: Any): Boolean = true + protected class ActivityViewHolder(val binding: ActivityListItemBinding) : RecyclerView.ViewHolder(binding.root) diff --git a/app/src/main/java/third_parties/fresco/BetterImageSpan.kt b/app/src/main/java/third_parties/fresco/BetterImageSpan.kt new file mode 100644 index 000000000000..da8a0bfff4a0 --- /dev/null +++ b/app/src/main/java/third_parties/fresco/BetterImageSpan.kt @@ -0,0 +1,119 @@ +/* + * SPDX-FileCopyrightText: 2015-present, Facebook, Inc. and its affiliates. + * SPDX-License-Identifier: MIT + */ + +package thirdparties.fresco + +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.Rect +import android.graphics.drawable.Drawable +import android.text.style.ReplacementSpan +import androidx.annotation.IntDef + +/** + * A better implementation of image spans that also supports centering images against the text. + * + * In order to migrate from ImageSpan, replace `new ImageSpan(drawable, alignment)` with + * `new BetterImageSpan(drawable, BetterImageSpan.normalizeAlignment(alignment))`. + * + * There are 2 main differences between BetterImageSpan and ImageSpan: + * 1. Pass in ALIGN_CENTER to center images against the text. + * 2. ALIGN_BOTTOM no longer unnecessarily increases the size of the text: + * DynamicDrawableSpan (ImageSpan's parent) adjusts sizes as if alignment was ALIGN_BASELINE + * which can lead to unnecessary whitespace. + */ +open class BetterImageSpan @JvmOverloads constructor( + val drawable: Drawable, + @param:BetterImageSpanAlignment private val mAlignment: Int = ALIGN_BASELINE +) : ReplacementSpan() { + @Suppress("Detekt.SpreadOperator") + @IntDef(*[ALIGN_BASELINE, ALIGN_BOTTOM, ALIGN_CENTER]) + @Retention(AnnotationRetention.SOURCE) + annotation class BetterImageSpanAlignment + + private var mWidth = 0 + private var mHeight = 0 + private var mBounds: Rect? = null + private val mFontMetricsInt = Paint.FontMetricsInt() + + init { + updateBounds() + } + + /** + * Returns the width of the image span and increases the height if font metrics are available. + */ + override fun getSize( + paint: Paint, + text: CharSequence, + start: Int, + end: Int, + fontMetrics: Paint.FontMetricsInt? + ): Int { + updateBounds() + if (fontMetrics == null) { + return mWidth + } + val offsetAbove = getOffsetAboveBaseline(fontMetrics) + val offsetBelow = mHeight + offsetAbove + if (offsetAbove < fontMetrics.ascent) { + fontMetrics.ascent = offsetAbove + } + if (offsetAbove < fontMetrics.top) { + fontMetrics.top = offsetAbove + } + if (offsetBelow > fontMetrics.descent) { + fontMetrics.descent = offsetBelow + } + if (offsetBelow > fontMetrics.bottom) { + fontMetrics.bottom = offsetBelow + } + return mWidth + } + + override fun draw( + canvas: Canvas, + text: CharSequence, + start: Int, + end: Int, + x: Float, + top: Int, + y: Int, + bottom: Int, + paint: Paint + ) { + paint.getFontMetricsInt(mFontMetricsInt) + val iconTop = y + getOffsetAboveBaseline(mFontMetricsInt) + canvas.translate(x, iconTop.toFloat()) + drawable.draw(canvas) + canvas.translate(-x, -iconTop.toFloat()) + } + + private fun updateBounds() { + mBounds = drawable.bounds + mWidth = mBounds!!.width() + mHeight = mBounds!!.height() + } + + private fun getOffsetAboveBaseline(fm: Paint.FontMetricsInt): Int = when (mAlignment) { + ALIGN_BOTTOM -> fm.descent - mHeight + + ALIGN_CENTER -> { + val textHeight = fm.descent - fm.ascent + val offset = (textHeight - mHeight) / 2 + fm.ascent + offset + } + + ALIGN_BASELINE -> -mHeight + + else -> -mHeight + } + + companion object { + const val ALIGN_BOTTOM = 0 + const val ALIGN_BASELINE = 1 + const val ALIGN_CENTER = 2 + } +} diff --git a/app/src/main/res/drawable/accent_circle.xml b/app/src/main/res/drawable/accent_circle.xml new file mode 100644 index 000000000000..3acd7c33c465 --- /dev/null +++ b/app/src/main/res/drawable/accent_circle.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/layout/activity_list_item.xml b/app/src/main/res/layout/activity_list_item.xml index ad17d9e40485..4e0bb51d201c 100644 --- a/app/src/main/res/layout/activity_list_item.xml +++ b/app/src/main/res/layout/activity_list_item.xml @@ -34,11 +34,13 @@ 3dp 16dp 10dp + 12dp 72dp 0dp 56dp diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index 3b9d1ef0ecb9..58c63b7577be 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -490,4 +490,8 @@ @android:color/black @color/primary + + diff --git a/app/src/main/res/xml/chip_others.xml b/app/src/main/res/xml/chip_others.xml new file mode 100644 index 000000000000..9f878b3edb22 --- /dev/null +++ b/app/src/main/res/xml/chip_others.xml @@ -0,0 +1,13 @@ + +