Skip to content

Commit eba6937

Browse files
Merge pull request #17397 from nextcloud/feat/add-chips-avatars-to-notification-screen
feat(chip): activities list adapter, notification list adapter
2 parents ba00e6b + 88b8010 commit eba6937

6 files changed

Lines changed: 196 additions & 152 deletions

File tree

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
/*
2+
* Nextcloud - Android Client
3+
*
4+
* SPDX-FileCopyrightText: 2026 Alper Ozturk <alper.ozturk@nextcloud.com>
5+
* SPDX-License-Identifier: AGPL-3.0-or-later
6+
*/
7+
package com.nextcloud.utils.text
8+
9+
import android.content.Context
10+
import android.graphics.Typeface
11+
import android.graphics.drawable.Drawable
12+
import android.text.Spannable
13+
import android.text.SpannableStringBuilder
14+
import android.text.TextPaint
15+
import android.text.TextUtils
16+
import android.text.style.ClickableSpan
17+
import android.text.style.ForegroundColorSpan
18+
import android.text.style.StyleSpan
19+
import android.view.View
20+
import androidx.core.content.ContextCompat
21+
import com.google.android.material.chip.ChipDrawable
22+
import com.nextcloud.client.account.CurrentAccountProvider
23+
import com.owncloud.android.R
24+
import com.owncloud.android.utils.DisplayUtils
25+
import thirdparties.fresco.BetterImageSpan
26+
27+
class RichSubjectFormatter(private val context: Context, private val currentAccountProvider: CurrentAccountProvider) :
28+
DisplayUtils.AvatarGenerationListener {
29+
30+
fun format(richSubject: String, paramForTag: (String) -> RichSubjectParam?): SpannableStringBuilder {
31+
var text = richSubject
32+
val ssb = SpannableStringBuilder(text)
33+
34+
var start = text.indexOf(PLACEHOLDER_START)
35+
while (start != -1) {
36+
val end = text.indexOf(PLACEHOLDER_END, start) + 1
37+
if (end <= start) break
38+
39+
val param = paramForTag(text.substring(start + 1, end - 1))
40+
41+
val nextSearchStart = when {
42+
param == null -> end
43+
44+
param.isMention -> {
45+
ssb.applyMentionSpan(param, start, end)
46+
end
47+
}
48+
49+
else -> {
50+
val nameEnd = ssb.applyClickableNameSpan(param, start, end)
51+
text = ssb.toString()
52+
nameEnd
53+
}
54+
}
55+
56+
start = text.indexOf(PLACEHOLDER_START, nextSearchStart)
57+
}
58+
59+
return ssb
60+
}
61+
62+
override fun avatarGenerated(avatarDrawable: Drawable, callContext: Any) {
63+
(callContext as? ChipDrawable)?.chipIcon = avatarDrawable
64+
}
65+
66+
override fun shouldCallGeneratedCallback(tag: String, callContext: Any): Boolean = true
67+
68+
private fun SpannableStringBuilder.applyMentionSpan(param: RichSubjectParam, start: Int, end: Int) {
69+
val name = param.name
70+
val chip = mentionChipDrawable(name.orEmpty())
71+
val span = MentionChipSpan(chip, BetterImageSpan.ALIGN_CENTER, param.id.orEmpty(), name)
72+
73+
param.id?.let { id ->
74+
DisplayUtils.setAvatar(
75+
currentAccountProvider.user,
76+
id,
77+
name,
78+
this@RichSubjectFormatter,
79+
context.resources.getDimension(R.dimen.avatar_icon_radius),
80+
context.resources,
81+
chip,
82+
context
83+
)
84+
}
85+
86+
setSpan(span, start, end, Spannable.SPAN_INCLUSIVE_EXCLUSIVE)
87+
}
88+
89+
private fun SpannableStringBuilder.applyClickableNameSpan(param: RichSubjectParam, start: Int, end: Int): Int {
90+
val name = param.name.orEmpty()
91+
replace(start, end, name)
92+
val nameEnd = start + name.length
93+
94+
param.onClick?.let { onClick ->
95+
setSpan(
96+
object : ClickableSpan() {
97+
override fun onClick(widget: View) = onClick()
98+
override fun updateDrawState(ds: TextPaint) {
99+
ds.isUnderlineText = false
100+
}
101+
},
102+
start,
103+
nameEnd,
104+
0
105+
)
106+
}
107+
108+
setSpan(StyleSpan(Typeface.BOLD), start, nameEnd, 0)
109+
setSpan(
110+
ForegroundColorSpan(ContextCompat.getColor(context, R.color.text_color)),
111+
start,
112+
nameEnd,
113+
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
114+
)
115+
116+
return nameEnd
117+
}
118+
119+
private fun mentionChipDrawable(text: String): ChipDrawable =
120+
ChipDrawable.createFromResource(context, R.xml.chip_others).apply {
121+
setEllipsize(TextUtils.TruncateAt.MIDDLE)
122+
layoutDirection = context.resources.configuration.layoutDirection
123+
setText(text)
124+
setChipIconResource(R.drawable.accent_circle)
125+
setBounds(0, 0, intrinsicWidth, intrinsicHeight)
126+
}
127+
128+
companion object {
129+
private const val PLACEHOLDER_START = '{'
130+
private const val PLACEHOLDER_END = '}'
131+
}
132+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
/*
2+
* Nextcloud - Android Client
3+
*
4+
* SPDX-FileCopyrightText: 2026 Alper Ozturk <alper.ozturk@nextcloud.com>
5+
* SPDX-License-Identifier: AGPL-3.0-or-later
6+
*/
7+
package com.nextcloud.utils.text
8+
9+
data class RichSubjectParam(val type: String?, val id: String?, val name: String?, val onClick: (() -> Unit)? = null) {
10+
val isMention: Boolean
11+
get() = type == USER_TYPE
12+
13+
companion object {
14+
private const val USER_TYPE = "user"
15+
}
16+
}

app/src/main/java/com/owncloud/android/ui/activities/adapter/ActivityListAdapter.kt

Lines changed: 12 additions & 106 deletions
Original file line numberDiff line numberDiff line change
@@ -7,17 +7,9 @@
77
package com.owncloud.android.ui.activities.adapter
88

99
import android.content.Context
10-
import android.graphics.Typeface
11-
import android.graphics.drawable.Drawable
12-
import android.text.Spannable
1310
import android.text.SpannableStringBuilder
14-
import android.text.TextPaint
15-
import android.text.TextUtils
1611
import android.text.format.DateFormat
1712
import android.text.format.DateUtils
18-
import android.text.style.ClickableSpan
19-
import android.text.style.ForegroundColorSpan
20-
import android.text.style.StyleSpan
2113
import android.view.LayoutInflater
2214
import android.view.View
2315
import android.view.ViewGroup
@@ -29,12 +21,12 @@ import androidx.annotation.DrawableRes
2921
import androidx.fragment.app.FragmentActivity
3022
import androidx.lifecycle.lifecycleScope
3123
import androidx.recyclerview.widget.RecyclerView
32-
import com.google.android.material.chip.ChipDrawable
3324
import com.nextcloud.android.common.ui.theme.utils.ColorRole
3425
import com.nextcloud.client.account.CurrentAccountProvider
3526
import com.nextcloud.common.NextcloudClient
3627
import com.nextcloud.utils.GlideHelper
37-
import com.nextcloud.utils.text.MentionChipSpan
28+
import com.nextcloud.utils.text.RichSubjectFormatter
29+
import com.nextcloud.utils.text.RichSubjectParam
3830
import com.owncloud.android.MainApp
3931
import com.owncloud.android.R
4032
import com.owncloud.android.databinding.ActivityListItemBinding
@@ -53,7 +45,6 @@ import com.owncloud.android.utils.theme.ViewThemeUtils
5345
import kotlinx.coroutines.Dispatchers
5446
import kotlinx.coroutines.launch
5547
import kotlinx.coroutines.withContext
56-
import thirdparties.fresco.BetterImageSpan
5748
import java.util.Locale
5849
import kotlin.math.floor
5950
import kotlin.math.log
@@ -67,13 +58,13 @@ open class ActivityListAdapter(
6758
private val isDetailView: Boolean,
6859
private val viewThemeUtils: ViewThemeUtils
6960
) : RecyclerView.Adapter<RecyclerView.ViewHolder>(),
70-
StickyHeaderAdapter,
71-
DisplayUtils.AvatarGenerationListener {
61+
StickyHeaderAdapter {
7262

7363
protected var client: NextcloudClient? = null
7464
val values: MutableList<Any> = mutableListOf()
7565
private val px = getThumbnailDimension()
7666
private var cachedNextcloudClient: NextcloudClient? = null
67+
private val richSubjectFormatter by lazy { RichSubjectFormatter(context, currentAccountProvider) }
7768

7869
// region Public Methods
7970
@Suppress("NotifyDataSetChanged")
@@ -138,12 +129,6 @@ open class ActivityListAdapter(
138129

139130
override fun isHeader(itemPosition: Int) =
140131
itemPosition in values.indices && getItemViewType(itemPosition) == HEADER_TYPE
141-
142-
override fun avatarGenerated(avatarDrawable: Drawable, callContext: Any) {
143-
(callContext as ChipDrawable).chipIcon = avatarDrawable
144-
}
145-
146-
override fun shouldCallGeneratedCallback(tag: String, callContext: Any): Boolean = true
147132
// endregion
148133

149134
// region Private Methods
@@ -248,15 +233,6 @@ open class ActivityListAdapter(
248233
}
249234
}
250235

251-
private fun getDrawableForMentionChipSpan(chipResource: Int, text: String): ChipDrawable =
252-
ChipDrawable.createFromResource(context, chipResource).apply {
253-
setEllipsize(TextUtils.TruncateAt.MIDDLE)
254-
layoutDirection = context.resources.configuration.layoutDirection
255-
setText(text)
256-
setChipIconResource(R.drawable.accent_circle)
257-
setBounds(0, 0, intrinsicWidth, intrinsicHeight)
258-
}
259-
260236
private suspend fun nextcloudClient(): NextcloudClient = cachedNextcloudClient ?: withContext(Dispatchers.IO) {
261237
OwnCloudClientManagerFactory.getDefaultSingleton()
262238
.getNextcloudClientFor(currentAccountProvider.user.toOwnCloudAccount(), context)
@@ -302,83 +278,16 @@ open class ActivityListAdapter(
302278
return imageView
303279
}
304280

305-
private fun addClickablePart(richElement: RichElement): SpannableStringBuilder {
306-
var text = richElement.richSubject
307-
val ssb = SpannableStringBuilder(text)
308-
309-
var start = text.indexOf(PLACEHOLDER_START)
310-
while (start != -1) {
311-
val end = text.indexOf(PLACEHOLDER_END, start) + 1
312-
val tag = text.substring(start + 1, end - 1)
313-
val richObject = richElement.richObjectList.firstOrNull { it.tag.equals(tag, ignoreCase = true) }
314-
315-
val nextSearchStart = when {
316-
richObject == null -> end
317-
318-
richObject.type == USER_TYPE -> {
319-
ssb.applyMentionSpan(richObject, start, end)
320-
end
321-
}
322-
323-
else -> {
324-
val nameEnd = ssb.applyClickableNameSpan(richObject, start, end)
325-
text = ssb.toString()
326-
nameEnd
327-
}
328-
}
329-
330-
start = text.indexOf(PLACEHOLDER_START, nextSearchStart)
331-
}
332-
333-
return ssb
334-
}
335-
336-
private fun SpannableStringBuilder.applyMentionSpan(richObject: RichObject, start: Int, end: Int) {
337-
val name = richObject.name
338-
val chip = getDrawableForMentionChipSpan(R.xml.chip_others, name ?: "")
339-
val span = MentionChipSpan(chip, BetterImageSpan.ALIGN_CENTER, richObject.id ?: "", name)
340-
341-
richObject.id?.let { id ->
342-
DisplayUtils.setAvatar(
343-
currentAccountProvider.user,
344-
id,
345-
name,
346-
this@ActivityListAdapter,
347-
context.resources.getDimension(R.dimen.avatar_icon_radius),
348-
context.resources,
349-
chip,
350-
context
351-
)
281+
private fun addClickablePart(richElement: RichElement): SpannableStringBuilder =
282+
richSubjectFormatter.format(richElement.richSubject) { tag ->
283+
richElement.richObjectList
284+
.firstOrNull { it.tag.equals(tag, ignoreCase = true) }
285+
?.toRichSubjectParam()
352286
}
353287

354-
setSpan(span, start, end, Spannable.SPAN_INCLUSIVE_EXCLUSIVE)
355-
}
356-
357-
private fun SpannableStringBuilder.applyClickableNameSpan(richObject: RichObject, start: Int, end: Int): Int {
358-
val name = richObject.name.orEmpty()
359-
replace(start, end, name)
360-
val nameEnd = start + name.length
361-
362-
setSpan(
363-
object : ClickableSpan() {
364-
override fun onClick(widget: View) = activityListInterface.onActivityClicked(richObject)
365-
override fun updateDrawState(ds: TextPaint) {
366-
ds.isUnderlineText = false
367-
}
368-
},
369-
start,
370-
nameEnd,
371-
0
372-
)
373-
setSpan(StyleSpan(Typeface.BOLD), start, nameEnd, 0)
374-
setSpan(
375-
ForegroundColorSpan(context.resources.getColor(R.color.text_color)),
376-
start,
377-
nameEnd,
378-
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
379-
)
380-
381-
return nameEnd
288+
private fun RichObject.toRichSubjectParam(): RichSubjectParam {
289+
val richObject = this
290+
return RichSubjectParam(type, id, name) { activityListInterface.onActivityClicked(richObject) }
382291
}
383292

384293
private fun getThumbnailDimension(): Int {
@@ -397,9 +306,6 @@ open class ActivityListAdapter(
397306
const val HEADER_TYPE = 100
398307
const val ACTIVITY_TYPE = 101
399308
private const val COLORED_ICON_SUFFIX = "-color.svg"
400-
private const val PLACEHOLDER_START = '{'
401-
private const val PLACEHOLDER_END = '}'
402-
private const val USER_TYPE = "user"
403309
private const val TIME_PATTERN = "HH:mm"
404310
private const val HEADER_DATE_SKELETON = "EEEE, MMMM d"
405311
private const val PREVIEW_COLUMN_SPACING = 20

0 commit comments

Comments
 (0)