Improve Talkback for tiles

Changes made:
- Shop items now read out name of item, price with currency, limited availability, items owned and locked state (previously just price)
- In equipment, name of item or none will be read out in addition to slot (previously just slot)
- In stable, hatchable pets will be read out as name of pet and "Hatch Pet" (previously unlabelled)
- In task creation, the click listener for the difficulty has been moved to the surrounding column so icon and text are grouped (previously icon was read as unlabeled and text was not clickable)
- In navigation drawer, the profile picture is read out as "Profile" (previously unlabelled)

My Habitica User-ID: 2bcd10dc-0ea9-4d95-b7a6-f55fa07af537
This commit is contained in:
AJ Keane 2025-05-31 16:21:03 +02:00 committed by Phillip Thelen
parent ff568fc68c
commit 07450f43ff
9 changed files with 152 additions and 96 deletions

View file

@ -33,7 +33,8 @@
android:id="@+id/avatarView"
android:layout_width="70dp"
android:layout_height="70dp"
android:layout_gravity="center" />
android:layout_gravity="center"
android:contentDescription="@string/profile"/>
</com.habitrpg.android.habitica.ui.RoundedFrameLayout>
<LinearLayout

View file

@ -47,6 +47,7 @@
android:textColor="@color/text_quad"
android:paddingStart="2dp"
android:paddingEnd="2dp"
android:accessibilityTraversalAfter="@id/container"
/>
</LinearLayout>
</LinearLayout>
@ -89,5 +90,6 @@
android:layout_gravity="end"
android:visibility="gone"
android:layout_alignEnd="@id/container"
android:importantForAccessibility="no"
style="@style/CountLabel"/>
</RelativeLayout>

View file

@ -201,6 +201,8 @@ class PetDetailRecyclerAdapter :
binding.eggView.startAnimation(Animations.bobbingAnimation(4f))
binding.hatchingPotionView.startAnimation(Animations.bobbingAnimation(-4f))
binding.root.contentDescription = "${item.text}, ${itemView.context.getString(R.string.hatch_pet)}"
}
override fun onClick(p0: View?) {

View file

@ -337,7 +337,7 @@ fun AvatarOverviewView(
userViewModel.updateUser("preferences.autoEquip", it)
})
}
EquipmentOverviewView(user?.items?.gear?.equipped, battleGearTwoHanded, { type, equipped ->
EquipmentOverviewView(user?.items?.gear?.owned, user?.items?.gear?.equipped, battleGearTwoHanded, { type, equipped ->
onEquipmentTap(type, equipped, false)
})
Row(
@ -362,7 +362,7 @@ fun AvatarOverviewView(
userViewModel.updateUser("preferences.costume", it)
})
}
EquipmentOverviewView(user?.items?.gear?.costume, costumeTwoHanded, { type, equipped ->
EquipmentOverviewView(user?.items?.gear?.owned, user?.items?.gear?.costume, costumeTwoHanded, { type, equipped ->
onEquipmentTap(type, equipped, true)
}, modifier = Modifier.alpha(if (user?.preferences?.costume == true) 1.0f else 0.5f))
}

View file

@ -8,6 +8,9 @@ import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.RecyclerView
import com.habitrpg.android.habitica.R
import com.habitrpg.android.habitica.databinding.RowShopitemBinding
import com.habitrpg.android.habitica.extensions.getImpreciseRemainingString
import com.habitrpg.android.habitica.extensions.getRemainingString
import com.habitrpg.android.habitica.extensions.getShortRemainingString
import com.habitrpg.android.habitica.models.shops.ShopItem
import com.habitrpg.android.habitica.ui.views.HabiticaIconsHelper
import com.habitrpg.common.habitica.extensions.dpToPx
@ -52,6 +55,8 @@ class ShopItemViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView), Vi
this.item = item
binding.buyButton.visibility = View.VISIBLE
var contentDescription = item.text
binding.imageView.loadImage(item.imageName?.replace("_locked", ""))
binding.itemDetailIndicator.text = null
@ -66,10 +71,12 @@ class ShopItemViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView), Vi
}
binding.priceLabel.visibility = View.VISIBLE
binding.unlockLabel.visibility = View.GONE
contentDescription += ", ${item.value} ${binding.priceLabel.currencyContentDescription}"
} else {
binding.unlockLabel.text = lockedReason
binding.priceLabel.visibility = View.GONE
binding.unlockLabel.visibility = View.VISIBLE
contentDescription += ", $lockedReason"
}
val isLimited = item.isLimited || item.availableUntil != null
if (numberOwned > 0) {
@ -82,10 +89,12 @@ class ShopItemViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView), Vi
binding.itemDetailIndicator.setTextColor(ContextCompat.getColor(context, R.color.text_quad))
}
binding.itemDetailIndicator.visibility = View.VISIBLE
contentDescription += ", ${context.getString(R.string.owned)}: $numberOwned"
} else if (item.locked) {
binding.itemDetailIndicator.background =
AppCompatResources.getDrawable(context, if (isLimited) R.drawable.shop_locked_limited else R.drawable.shop_locked)
binding.itemDetailIndicator.visibility = View.VISIBLE
contentDescription += ", ${context.getString(R.string.locked)}"
} else if (isLimited) {
if (numberOwned == 0) {
binding.itemDetailIndicator.background = AppCompatResources.getDrawable(context, R.drawable.shop_limited)
@ -93,6 +102,10 @@ class ShopItemViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView), Vi
binding.itemDetailIndicator.background = AppCompatResources.getDrawable(context, R.drawable.pill_bg_purple_300)
}
binding.itemDetailIndicator.visibility = View.VISIBLE
item.availableUntil?.let {
contentDescription += ", ${it.getImpreciseRemainingString(context.resources)}"
}
}
val limitedLeft = item.limitedNumberLeft ?: limitedNumberLeft
@ -100,12 +113,14 @@ class ShopItemViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView), Vi
binding.itemDetailIndicator.background =
AppCompatResources.getDrawable(context, R.drawable.item_indicator_subscribe)
binding.itemDetailIndicator.visibility = View.VISIBLE
contentDescription += ", ${context.getString(R.string.locked)}"
} else if (item.key == "gem") {
binding.itemDetailIndicator.background =
AppCompatResources.getDrawable(context, R.drawable.pill_bg_green)
binding.itemDetailIndicator.text = "$limitedLeft"
binding.itemDetailIndicator.setTextColor(ContextCompat.getColor(context, R.color.white))
binding.itemDetailIndicator.visibility = View.VISIBLE
contentDescription += ", ${context.getString(R.string.gems_left_nomax, limitedLeft)}"
}
if (binding.itemDetailIndicator.visibility == View.VISIBLE) {
@ -120,6 +135,8 @@ class ShopItemViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView), Vi
}
binding.priceLabel.isLocked = item.locked || (!canBuy && item.currency == "gold")
binding.container.contentDescription = contentDescription
}
override fun onClick(view: View) {

View file

@ -11,6 +11,9 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
@ -58,7 +61,16 @@ fun CurrencyText(
} else {
value.toFloat()
}
Row(verticalAlignment = Alignment.CenterVertically, modifier = modifier) {
val currencyName = when (currency) {
"gold" -> stringResource(R.string.gold_plural)
"gems" -> stringResource(R.string.gems)
"hourglasses" -> stringResource(R.string.mystic_hourglasses)
else -> ""
}
val amount = NumberAbbreviator.abbreviate(null, animatedValue, decimals, minForAbbreviation)
Row(verticalAlignment = Alignment.CenterVertically, modifier = modifier.semantics(mergeDescendants = true) {
contentDescription = "$amount $currencyName"
}) {
when (currency) {
"gold" -> HabiticaIconsHelper.imageOfGold()
"gems" -> HabiticaIconsHelper.imageOfGem()
@ -66,14 +78,14 @@ fun CurrencyText(
else -> null
}?.asImageBitmap()?.let { Image(it, null, Modifier.padding(end = 5.dp)) }
Text(
NumberAbbreviator.abbreviate(null, animatedValue, decimals, minForAbbreviation),
text = amount,
color =
when (currency) {
"gold" -> colorResource(R.color.text_gold)
"gems" -> colorResource(R.color.text_green)
"hourglasses" -> colorResource(R.color.text_brand)
else -> colorResource(R.color.text_primary)
},
when (currency) {
"gold" -> colorResource(R.color.text_gold)
"gems" -> colorResource(R.color.text_green)
"hourglasses" -> colorResource(R.color.text_brand)
else -> colorResource(R.color.text_primary)
},
fontSize = fontSize,
fontWeight = FontWeight.SemiBold
)

View file

@ -31,7 +31,8 @@ class CurrencyView : androidx.appcompat.widget.AppCompatTextView {
configureCurrency()
updateVisibility()
}
private var currencyContentDescription: String? = null
var currencyContentDescription: String? = null
private set
constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) {
val attributes =

View file

@ -20,10 +20,15 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.isTraversalGroup
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.traversalIndex
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.habitrpg.android.habitica.R
import com.habitrpg.android.habitica.models.inventory.Equipment
import com.habitrpg.android.habitica.models.user.Outfit
import com.habitrpg.android.habitica.models.user.Preferences
import com.habitrpg.android.habitica.ui.theme.colors
@ -31,32 +36,36 @@ import com.habitrpg.android.habitica.ui.theme.pixelArtBackground
import com.habitrpg.android.habitica.ui.views.PixelArtView
import com.habitrpg.common.habitica.theme.HabiticaTheme
import com.habitrpg.common.habitica.theme.caption2
import io.realm.RealmList
@Composable
fun OverviewItem(
text: String,
iconName: String?,
itemName: String = "",
modifier: Modifier = Modifier,
isTwoHanded: Boolean = false
) {
val hasIcon =
isTwoHanded || (
iconName?.isNotBlank() == true && iconName != "shirt_" &&
!iconName.endsWith(
"_none"
) && !iconName.endsWith("_base_0") && !iconName.endsWith("_")
)
iconName?.isNotBlank() == true && iconName != "shirt_" &&
!iconName.endsWith(
"_none"
) && !iconName.endsWith("_base_0") && !iconName.endsWith("_")
)
val description = if (hasIcon) itemName else stringResource(R.string.none)
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier =
modifier
.width(76.dp)
modifier
.width(76.dp)
) {
Box(
Modifier
.size(76.dp)
.clip(MaterialTheme.shapes.small)
.background(HabiticaTheme.colors.pixelArtBackground(hasIcon)),
.background(HabiticaTheme.colors.pixelArtBackground(hasIcon))
.semantics { contentDescription = description },
contentAlignment = Alignment.Center
) {
if (isTwoHanded) {
@ -65,8 +74,8 @@ fun OverviewItem(
PixelArtView(
imageName = iconName,
modifier =
Modifier
.size(76.dp)
Modifier
.size(76.dp)
)
} else {
Image(painterResource(R.drawable.empty_slot), null)
@ -77,13 +86,15 @@ fun OverviewItem(
style = HabiticaTheme.typography.caption2,
color = colorResource(R.color.text_secondary),
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 4.dp)
modifier = Modifier
.padding(top = 4.dp)
)
}
}
@Composable
fun EquipmentOverviewView(
owned: RealmList<Equipment>?,
outfit: Outfit?,
isUsingTwohanded: Boolean,
onEquipmentTap: (String, String?) -> Unit,
@ -92,79 +103,87 @@ fun EquipmentOverviewView(
Column(
verticalArrangement = Arrangement.spacedBy(18.dp),
modifier =
modifier
.fillMaxWidth()
.clip(MaterialTheme.shapes.medium)
.background(colorResource(R.color.equipment_column_background))
.padding(12.dp)
modifier
.fillMaxWidth()
.clip(MaterialTheme.shapes.medium)
.background(colorResource(R.color.equipment_column_background))
.padding(12.dp)
) {
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
OverviewItem(
stringResource(R.string.outfit_weapon),
outfit?.weapon.let { "shop_$it" },
itemName = owned?.firstOrNull { it.key == outfit?.weapon }?.text ?: "",
modifier =
Modifier.clickable {
onEquipmentTap("weapon", outfit?.weapon)
}
Modifier.clickable {
onEquipmentTap("weapon", outfit?.weapon)
}
)
OverviewItem(
stringResource(R.string.outfit_shield),
outfit?.shield.let { "shop_$it" },
itemName = owned?.firstOrNull { it.key == outfit?.shield }?.text ?: "",
modifier =
Modifier.clickable {
onEquipmentTap("shield", outfit?.shield)
},
Modifier.clickable {
onEquipmentTap("shield", outfit?.shield)
},
isUsingTwohanded
)
OverviewItem(
stringResource(R.string.outfit_head),
outfit?.head.let { "shop_$it" },
itemName = owned?.firstOrNull { it.key == outfit?.head }?.text ?: "",
modifier =
Modifier.clickable {
onEquipmentTap("head", outfit?.head)
}
Modifier.clickable {
onEquipmentTap("head", outfit?.head)
}
)
OverviewItem(
stringResource(R.string.outfit_armor),
outfit?.armor.let { "shop_$it" },
itemName = owned?.firstOrNull { it.key == outfit?.armor }?.text ?: "",
modifier =
Modifier.clickable {
onEquipmentTap("armor", outfit?.armor)
}
Modifier.clickable {
onEquipmentTap("armor", outfit?.armor)
}
)
}
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
OverviewItem(
stringResource(R.string.outfit_headAccessory),
outfit?.headAccessory.let { "shop_$it" },
itemName = owned?.firstOrNull { it.key == outfit?.headAccessory }?.text ?: "",
modifier =
Modifier.clickable {
onEquipmentTap("headAccessory", outfit?.headAccessory)
}
Modifier.clickable {
onEquipmentTap("headAccessory", outfit?.headAccessory)
}
)
OverviewItem(
stringResource(R.string.outfit_body),
outfit?.body.let { "shop_$it" },
itemName = owned?.firstOrNull { it.key == outfit?.body }?.text ?: "",
modifier =
Modifier.clickable {
onEquipmentTap("body", outfit?.body)
}
Modifier.clickable {
onEquipmentTap("body", outfit?.body)
}
)
OverviewItem(
stringResource(R.string.outfit_back),
outfit?.back.let { "shop_$it" },
itemName = owned?.firstOrNull { it.key == outfit?.back }?.text ?: "",
modifier =
Modifier.clickable {
onEquipmentTap("back", outfit?.back)
}
Modifier.clickable {
onEquipmentTap("back", outfit?.back)
}
)
OverviewItem(
stringResource(R.string.outfit_eyewear),
outfit?.eyeWear.let { "shop_$it" },
itemName = owned?.firstOrNull { it.key == outfit?.eyeWear }?.text ?: "",
modifier =
Modifier.clickable {
onEquipmentTap("eyewear", outfit?.eyeWear)
}
Modifier.clickable {
onEquipmentTap("eyewear", outfit?.eyeWear)
}
)
}
}
@ -181,44 +200,44 @@ fun AvatarCustomizationOverviewView(
Column(
verticalArrangement = Arrangement.spacedBy(18.dp),
modifier =
modifier
.fillMaxWidth()
.clip(MaterialTheme.shapes.medium)
.background(colorResource(R.color.equipment_column_background))
.padding(12.dp)
modifier
.fillMaxWidth()
.clip(MaterialTheme.shapes.medium)
.background(colorResource(R.color.equipment_column_background))
.padding(12.dp)
) {
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
OverviewItem(
stringResource(R.string.avatar_shirt),
preferences?.shirt.let { "icon_${preferences?.size}_shirt_$it" },
modifier =
Modifier.clickable {
onCustomizationTap("shirt", null)
}
Modifier.clickable {
onCustomizationTap("shirt", null)
}
)
OverviewItem(
stringResource(R.string.avatar_skin),
preferences?.skin.let { "icon_skin_$it" },
modifier =
Modifier.clickable {
onCustomizationTap("skin", null)
}
Modifier.clickable {
onCustomizationTap("skin", null)
}
)
OverviewItem(
stringResource(R.string.avatar_hair_color),
if (preferences?.hair?.color != null && preferences.hair?.color != "") "icon_hair_bangs_1_" + preferences.hair?.color else "",
modifier =
Modifier.clickable {
onCustomizationTap("hair", "color")
}
Modifier.clickable {
onCustomizationTap("hair", "color")
}
)
OverviewItem(
stringResource(R.string.avatar_hair_bangs),
if (preferences?.hair?.bangs != null && preferences.hair?.bangs != 0) "icon_hair_bangs_" + preferences.hair?.bangs + "_" + preferences.hair?.color else "",
modifier =
Modifier.clickable {
onCustomizationTap("hair", "bangs")
}
Modifier.clickable {
onCustomizationTap("hair", "bangs")
}
)
}
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
@ -226,33 +245,33 @@ fun AvatarCustomizationOverviewView(
stringResource(R.string.avatar_style),
if (preferences?.hair?.base != null && preferences.hair?.base != 0) "icon_hair_base_" + preferences.hair?.base + "_" + preferences.hair?.color else "",
modifier =
Modifier.clickable {
onCustomizationTap("hair", "base")
}
Modifier.clickable {
onCustomizationTap("hair", "base")
}
)
OverviewItem(
stringResource(R.string.avatar_mustache),
if (preferences?.hair?.mustache != null && preferences.hair?.mustache != 0) "icon_hair_mustache_" + preferences.hair?.mustache + "_" + preferences.hair?.color else "",
modifier =
Modifier.clickable {
onCustomizationTap("hair", "mustache")
}
Modifier.clickable {
onCustomizationTap("hair", "mustache")
}
)
OverviewItem(
stringResource(R.string.avatar_beard),
if (preferences?.hair?.beard != null && preferences.hair?.beard != 0) "icon_hair_beard_" + preferences.hair?.beard + "_" + preferences.hair?.color else "",
modifier =
Modifier.clickable {
onCustomizationTap("hair", "beard")
}
Modifier.clickable {
onCustomizationTap("hair", "beard")
}
)
OverviewItem(
stringResource(R.string.avatar_flower),
if (preferences?.hair?.flower != null && preferences.hair?.flower != 0) "icon_hair_flower_" + preferences.hair?.flower else "",
modifier =
Modifier.clickable {
onCustomizationTap("hair", "flower")
}
Modifier.clickable {
onCustomizationTap("hair", "flower")
}
)
}
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
@ -260,33 +279,33 @@ fun AvatarCustomizationOverviewView(
stringResource(R.string.avatar_wheelchair),
preferences?.chair?.let { if (it.startsWith("handleless")) "icon_chair_$it" else "icon_$it" },
modifier =
Modifier.clickable {
onCustomizationTap("chair", null)
}
Modifier.clickable {
onCustomizationTap("chair", null)
}
)
OverviewItem(
stringResource(R.string.avatar_background),
preferences?.background.let { "icon_background_$it" },
modifier =
Modifier.clickable {
onCustomizationTap("background", null)
}
Modifier.clickable {
onCustomizationTap("background", null)
}
)
OverviewItem(
stringResource(R.string.animal_ears),
outfit?.headAccessory.let { "shop_$it" },
modifier =
Modifier.clickable {
onAvatarEquipmentTap("headAccessory", "animal")
}
Modifier.clickable {
onAvatarEquipmentTap("headAccessory", "animal")
}
)
OverviewItem(
stringResource(R.string.animal_tail),
outfit?.back.let { "shop_$it" },
modifier =
Modifier.clickable {
onAvatarEquipmentTap("back", "animal")
}
Modifier.clickable {
onAvatarEquipmentTap("back", "animal")
}
)
}
}
@ -304,7 +323,7 @@ fun EquipmentOverviewItemPreview() {
OverviewItem("Off-Hand", null, isTwoHanded = true)
OverviewItem("Armor", null)
}
EquipmentOverviewView(null, false, { _, _ -> })
EquipmentOverviewView(null, null, false, { _, _ -> })
AvatarCustomizationOverviewView(null, null, { _, _ -> }, { _, _ -> })
}
}

View file

@ -33,6 +33,9 @@ import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.isTraversalGroup
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
@ -100,7 +103,7 @@ private fun TaskDifficultySelection(
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(6.dp),
modifier = modifier
modifier = modifier.clickable { onSelect(value) }
) {
Box(
contentAlignment = Alignment.Center,
@ -114,7 +117,6 @@ private fun TaskDifficultySelection(
MaterialTheme.shapes.medium
)
.clip(MaterialTheme.shapes.medium)
.clickable { onSelect(value) }
) {
this@Column.AnimatedVisibility(
selected,