add role badges (#4029)

This adds support for the new Mastodon 4.2 role badges. Admins can
define if a role should be visible in the interface and then we get it
delivered by the Api on the `Account` object like this:
```
"roles": [
        {
              "id": "4",
              "name": "TEST",
              "color": "#ffee00"
        }
  ]
```

- keeps compatibility with older Mastodon version and non Mastodon
servers
- Took me a while, but I figured out a way to use the color and have it
look ok on all backgrounds (Mastodon itself ignores the color and just
always uses its brand color)
- falls back to Tusky blue in case no color is configured
- I adjusted the "Follows you" and "Bot" badges so they match the new
badge style
- In case the "Follows you" and "Bot" badges are visible at the same
time, "Follows you" gets its own line and "Bot" goes into the same line
as the role badge.
- Will work even with a lot of role badges (right now users can only
have 1 role at once though)
- Will work even when the badges federate (right now they don't)

<img
src="https://github.com/tuskyapp/Tusky/assets/10157047/24cbe889-ae46-408e-bfa0-cf3fd3c24f74"
width="320" />
This commit is contained in:
Konrad Pozniak 2023-09-25 09:47:27 +02:00 committed by GitHub
parent 82bc48c3ae
commit 5764c903e1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 132 additions and 36 deletions

View File

@ -22,9 +22,12 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.res.ColorStateList import android.content.res.ColorStateList
import android.graphics.Color import android.graphics.Color
import android.graphics.Typeface
import android.graphics.drawable.LayerDrawable import android.graphics.drawable.LayerDrawable
import android.os.Bundle import android.os.Bundle
import android.text.SpannableStringBuilder
import android.text.TextWatcher import android.text.TextWatcher
import android.text.style.StyleSpan
import android.view.Menu import android.view.Menu
import android.view.MenuInflater import android.view.MenuInflater
import android.view.MenuItem import android.view.MenuItem
@ -32,10 +35,12 @@ import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.annotation.ColorInt import androidx.annotation.ColorInt
import androidx.annotation.DrawableRes
import androidx.annotation.Px import androidx.annotation.Px
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.appcompat.content.res.AppCompatResources import androidx.appcompat.content.res.AppCompatResources
import androidx.core.app.ActivityOptionsCompat import androidx.core.app.ActivityOptionsCompat
import androidx.core.graphics.ColorUtils
import androidx.core.view.MenuProvider import androidx.core.view.MenuProvider
import androidx.core.view.ViewCompat import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
@ -48,6 +53,7 @@ import androidx.recyclerview.widget.LinearLayoutManager
import androidx.viewpager2.widget.MarginPageTransformer import androidx.viewpager2.widget.MarginPageTransformer
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.google.android.material.appbar.AppBarLayout import com.google.android.material.appbar.AppBarLayout
import com.google.android.material.chip.Chip
import com.google.android.material.color.MaterialColors import com.google.android.material.color.MaterialColors
import com.google.android.material.floatingactionbutton.FloatingActionButton import com.google.android.material.floatingactionbutton.FloatingActionButton
import com.google.android.material.shape.MaterialShapeDrawable import com.google.android.material.shape.MaterialShapeDrawable
@ -475,10 +481,10 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide
accountFieldAdapter.notifyDataSetChanged() accountFieldAdapter.notifyDataSetChanged()
binding.accountLockedImageView.visible(account.locked) binding.accountLockedImageView.visible(account.locked)
binding.accountBadgeTextView.visible(account.bot)
updateAccountAvatar() updateAccountAvatar()
updateToolbar() updateToolbar()
updateBadges()
updateMovedAccount() updateMovedAccount()
updateRemoteAccount() updateRemoteAccount()
updateAccountJoinedDate() updateAccountJoinedDate()
@ -491,6 +497,33 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide
} }
} }
private fun updateBadges() {
binding.accountBadgeContainer.removeAllViews()
val isLight = resources.getBoolean(R.bool.lightNavigationBar)
if (loadedAccount?.bot == true) {
val badgeView = getBadge(getColor(R.color.tusky_grey_50), R.drawable.ic_bot_24dp, getString(R.string.profile_badge_bot_text), isLight)
binding.accountBadgeContainer.addView(badgeView)
}
loadedAccount?.roles?.forEach { role ->
val badgeColor = if (role.color.isNotBlank()) {
Color.parseColor(role.color)
} else {
// sometimes the color is not set for a role, in this case fall back to our default blue
getColor(R.color.tusky_blue)
}
val sb = SpannableStringBuilder("${role.name} ${viewModel.domain}")
sb.setSpan(StyleSpan(Typeface.BOLD), 0, role.name.length, 0)
val badgeView = getBadge(badgeColor, R.drawable.profile_badge_person_24dp, sb, isLight)
binding.accountBadgeContainer.addView(badgeView)
}
}
private fun updateAccountJoinedDate() { private fun updateAccountJoinedDate() {
loadedAccount?.let { account -> loadedAccount?.let { account ->
try { try {
@ -1003,6 +1036,46 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide
} }
} }
private fun getBadge(
@ColorInt baseColor: Int,
@DrawableRes icon: Int,
text: CharSequence,
isLight: Boolean
): Chip {
val badge = Chip(this)
// text color with maximum contrast
val textColor = if (isLight) Color.BLACK else Color.WHITE
// badge color with 50% transparency so it blends in with the theme background
val backgroundColor = Color.argb(128, Color.red(baseColor), Color.green(baseColor), Color.blue(baseColor))
// a color between the text color and the badge color
val outlineColor = ColorUtils.blendARGB(textColor, baseColor, 0.7f)
// configure the badge
badge.text = text
badge.setTextColor(textColor)
badge.chipStrokeWidth = resources.getDimension(R.dimen.profile_badge_stroke_width)
badge.chipStrokeColor = ColorStateList.valueOf(outlineColor)
badge.setChipIconResource(icon)
badge.isChipIconVisible = true
badge.chipIconSize = resources.getDimension(R.dimen.profile_badge_icon_size)
badge.chipIconTint = ColorStateList.valueOf(outlineColor)
badge.chipBackgroundColor = ColorStateList.valueOf(backgroundColor)
// badge isn't clickable, so disable all related behavior
badge.isClickable = false
badge.isFocusable = false
badge.setEnsureMinTouchTargetSize(false)
// reset some chip defaults so it looks better for our badge usecase
badge.iconStartPadding = resources.getDimension(R.dimen.profile_badge_icon_start_padding)
badge.iconEndPadding = resources.getDimension(R.dimen.profile_badge_icon_end_padding)
badge.minHeight = resources.getDimensionPixelSize(R.dimen.profile_badge_min_height)
badge.chipMinHeight = resources.getDimension(R.dimen.profile_badge_min_height)
badge.updatePadding(top = 0, bottom = 0)
return badge
}
override fun androidInjector() = dispatchingAndroidInjector override fun androidInjector() = dispatchingAndroidInjector
companion object { companion object {

View File

@ -42,6 +42,9 @@ class AccountViewModel @Inject constructor(
lateinit var accountId: String lateinit var accountId: String
var isSelf = false var isSelf = false
/** the domain of the viewed account **/
var domain = ""
/** True if the viewed account has the same domain as the active account */ /** True if the viewed account has the same domain as the active account */
var isFromOwnDomain = false var isFromOwnDomain = false
@ -68,11 +71,12 @@ class AccountViewModel @Inject constructor(
mastodonApi.account(accountId) mastodonApi.account(accountId)
.fold( .fold(
{ account -> { account ->
domain = getDomain(account.url)
isFromOwnDomain = domain == activeAccount.domain
accountData.postValue(Success(account)) accountData.postValue(Success(account))
isDataLoading = false isDataLoading = false
isRefreshing.postValue(false) isRefreshing.postValue(false)
isFromOwnDomain = getDomain(account.url) == activeAccount.domain
}, },
{ t -> { t ->
Log.w(TAG, "failed obtaining account", t) Log.w(TAG, "failed obtaining account", t)

View File

@ -36,8 +36,8 @@ data class Account(
val bot: Boolean = false, val bot: Boolean = false,
val emojis: List<Emoji>? = emptyList(), // nullable for backward compatibility val emojis: List<Emoji>? = emptyList(), // nullable for backward compatibility
val fields: List<Field>? = emptyList(), // nullable for backward compatibility val fields: List<Field>? = emptyList(), // nullable for backward compatibility
val moved: Account? = null val moved: Account? = null,
val roles: List<Role>? = emptyList()
) { ) {
val name: String val name: String
@ -68,3 +68,8 @@ data class StringField(
val name: String, val name: String,
val value: String val value: String
) )
data class Role(
val name: String,
val color: String
)

View File

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<stroke android:width="1dp" android:color="?android:attr/textColorTertiary"/>
<corners android:radius="12dp"/>
<padding android:bottom="2dp" android:top="2dp" android:left="8dp" android:right="8dp"/>
</shape>

View File

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="m12,12c2.688,0 4.864,-2.177 4.864,-4.864 0,-2.688 -2.177,-4.864 -4.864,-4.864 -2.688,0 -4.864,2.177 -4.864,4.864C7.136,9.823 9.312,12 12,12ZM12,14.432c-3.247,0 -9.729,1.63 -9.729,4.864v2.432L21.729,21.729L21.729,19.297c0,-3.235 -6.482,-4.864 -9.729,-4.864z"
android:strokeWidth="1.2161"
android:fillColor="#000000"/>
</vector>

View File

@ -34,8 +34,8 @@
android:layout_height="180dp" android:layout_height="180dp"
android:layout_alignTop="@+id/account_header_info" android:layout_alignTop="@+id/account_header_info"
android:background="?attr/colorPrimaryDark" android:background="?attr/colorPrimaryDark"
android:scaleType="centerCrop"
android:contentDescription="@string/label_header" android:contentDescription="@string/label_header"
android:scaleType="centerCrop"
app:layout_collapseMode="parallax" app:layout_collapseMode="parallax"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toTopOf="parent"
@ -154,40 +154,42 @@
app:tint="?android:textColorSecondary" app:tint="?android:textColorSecondary"
tools:visibility="visible" /> tools:visibility="visible" />
<TextView <com.google.android.material.chip.Chip
android:id="@+id/accountFollowsYouTextView" android:id="@+id/accountFollowsYouTextView"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="6dp" android:layout_marginTop="6dp"
android:background="@drawable/profile_badge_background" android:clickable="false"
android:focusable="false"
android:minHeight="@dimen/profile_badge_min_height"
android:text="@string/follows_you" android:text="@string/follows_you"
android:textSize="?attr/status_text_small" android:textSize="?attr/status_text_small"
android:visibility="gone" android:visibility="gone"
app:chipBackgroundColor="#0000"
app:chipMinHeight="@dimen/profile_badge_min_height"
app:chipStrokeColor="?android:attr/textColorTertiary"
app:chipStrokeWidth="@dimen/profile_badge_stroke_width"
app:ensureMinTouchTargetSize="false"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/accountUsernameTextView" app:layout_constraintTop_toBottomOf="@id/accountUsernameTextView"
tools:visibility="visible" /> tools:visibility="visible" />
<TextView <com.google.android.material.chip.ChipGroup
android:id="@+id/accountBadgeTextView" android:id="@+id/accountBadgeContainer"
android:layout_width="wrap_content" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginTop="6dp" android:layout_marginTop="6dp"
android:background="@drawable/profile_badge_background" app:chipSpacingVertical="4dp"
android:text="@string/profile_badge_bot_text" app:layout_constraintStart_toEndOf="@id/accountUsernameTextView"
android:textSize="?attr/status_text_small" app:layout_constraintTop_toBottomOf="@id/accountFollowsYouTextView"
android:visibility="gone" app:layout_goneMarginStart="0dp" />
app:layout_constraintStart_toEndOf="@id/accountFollowsYouTextView"
app:layout_constraintTop_toBottomOf="@id/accountUsernameTextView"
app:layout_goneMarginStart="0dp"
tools:visibility="visible" />
<androidx.constraintlayout.widget.Barrier <androidx.constraintlayout.widget.Barrier
android:id="@+id/labelBarrier" android:id="@+id/labelBarrier"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
app:barrierDirection="bottom" app:barrierDirection="bottom"
app:constraint_referenced_ids="accountFollowsYouTextView,accountBadgeTextView" /> app:constraint_referenced_ids="accountFollowsYouTextView,accountBadgeContainer" />
<com.google.android.material.textfield.TextInputLayout <com.google.android.material.textfield.TextInputLayout
android:id="@+id/accountNoteTextInputLayout" android:id="@+id/accountNoteTextInputLayout"
@ -225,10 +227,11 @@
android:lineSpacingMultiplier="1.1" android:lineSpacingMultiplier="1.1"
android:paddingTop="2dp" android:paddingTop="2dp"
android:textColor="?android:textColorTertiary" android:textColor="?android:textColorTertiary"
android:textDirection="firstStrong"
android:textIsSelectable="true" android:textIsSelectable="true"
android:textSize="?attr/status_text_medium" android:textSize="?attr/status_text_medium"
android:textDirection="firstStrong"
app:layout_constraintTop_toBottomOf="@id/saveNoteInfo" app:layout_constraintTop_toBottomOf="@id/saveNoteInfo"
app:layout_goneMarginTop="8dp"
tools:text="This is a test description. Descriptions can be quite looooong." /> tools:text="This is a test description. Descriptions can be quite looooong." />
<androidx.recyclerview.widget.RecyclerView <androidx.recyclerview.widget.RecyclerView
@ -245,12 +248,12 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="8dp" android:layout_marginTop="8dp"
android:layout_marginBottom="8dp" android:layout_marginBottom="8dp"
tools:text="April, 1971"
android:textColor="@color/textColorSecondary" android:textColor="@color/textColorSecondary"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintBottom_toTopOf="@id/accountRemoveView"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/accountFieldList" app:layout_constraintTop_toBottomOf="@id/accountFieldList"
app:layout_constraintBottom_toTopOf="@id/accountRemoveView"/> tools:text="April, 1971" />
<TextView <TextView
android:id="@+id/accountRemoveView" android:id="@+id/accountRemoveView"
@ -269,8 +272,8 @@
android:id="@+id/accountMovedView" android:id="@+id/accountMovedView"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:visibility="gone"
android:layout_marginTop="4dp" android:layout_marginTop="4dp"
android:visibility="gone"
app:layout_constraintTop_toBottomOf="@id/accountRemoveView" app:layout_constraintTop_toBottomOf="@id/accountRemoveView"
tools:visibility="visible"> tools:visibility="visible">
@ -287,16 +290,16 @@
tools:text="Account has moved" /> tools:text="Account has moved" />
<ImageView <ImageView
android:importantForAccessibility="no"
android:id="@+id/accountMovedAvatar" android:id="@+id/accountMovedAvatar"
android:layout_width="48dp" android:layout_width="48dp"
android:layout_height="48dp" android:layout_height="48dp"
android:layout_centerVertical="true" android:layout_centerVertical="true"
android:layout_marginTop="8dp" android:layout_marginTop="8dp"
android:layout_marginBottom="8dp"
android:layout_marginEnd="24dp" android:layout_marginEnd="24dp"
app:layout_constraintStart_toStartOf="parent" android:layout_marginBottom="8dp"
android:importantForAccessibility="no"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/accountMovedText" app:layout_constraintTop_toBottomOf="@id/accountMovedText"
tools:src="@drawable/avatar_default" /> tools:src="@drawable/avatar_default" />
@ -471,8 +474,8 @@
android:layout_width="@dimen/account_activity_avatar_size" android:layout_width="@dimen/account_activity_avatar_size"
android:layout_height="@dimen/account_activity_avatar_size" android:layout_height="@dimen/account_activity_avatar_size"
android:layout_marginStart="16dp" android:layout_marginStart="16dp"
android:padding="3dp"
android:contentDescription="@string/label_avatar" android:contentDescription="@string/label_avatar"
android:padding="3dp"
app:layout_anchor="@+id/accountHeaderInfoContainer" app:layout_anchor="@+id/accountHeaderInfoContainer"
app:layout_anchorGravity="top" app:layout_anchorGravity="top"
app:layout_scrollFlags="scroll" app:layout_scrollFlags="scroll"

View File

@ -66,4 +66,11 @@
<dimen name="timeline_status_avatar_height">48dp</dimen> <dimen name="timeline_status_avatar_height">48dp</dimen>
<dimen name="timeline_status_avatar_width">48dp</dimen> <dimen name="timeline_status_avatar_width">48dp</dimen>
<dimen name="profile_badge_stroke_width">1dp</dimen>
<dimen name="profile_badge_min_height">24dp</dimen>
<dimen name="profile_badge_icon_size">16dp</dimen>
<dimen name="profile_badge_icon_start_padding">8dp</dimen>
<dimen name="profile_badge_icon_end_padding">0dp</dimen>
</resources> </resources>