From 4eacf9c2be6b25835b82e251dee5e369402678e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fernando=20Garc=C3=ADa=20Lid=C3=B3n?= Date: Tue, 27 Aug 2024 18:27:45 +0200 Subject: [PATCH] ANDROID-14884. Improve accessibility for XML list items (Classic) (#376) * ANDROID-14884. Add new ListRowItem subcomponent for Switch * ANDROID: 14884 Add. Initial contentDescription proposal for RowList * ANDROID-14884 Update. finish main logic for customContentDescription * ANDROID-14884 Update. Refactor code * ANDROID-14884 Update. Improve headline contentDescription logic * ANDROID-14884 Update. Expose new components into Catalog place * ANDROID-14884 Add. Warning when using Toggleable in ListRowView component * ANDROID-14884 Add. Testing for ListRowItem toggleables * Updated screenshots baseline * ANDROID-14884 Update. Improve a11y compatibility with headings * ANDROID-14884 Update. New sub-component documentation * ANDROID-14884 Update. Improve documentation readability * ANDROID-14884 Add. Translations to toggle action click accessibility info * ANDROID-14884 Update. Improve code readability * ANDROID-14884 Update. Apply review suggestions * ANDROID-14884 Fix. Some references to getActionLayout method with crashes * ANDROID-14884 Update. Setup disable override logic for a11y * ANDROID-14884 Update. Disable automatic announcement for Novum testing * ANDROID-14884 Remove. Title heading a11y logic since it triggers a bad impl behavior --------- Co-authored-by: haynlo --- .../components/ListsCatalogFragment.kt | 73 +++++-- .../layout/screen_fragment_lists_catalog.xml | 21 +- ...gment_lists_catalog_item_with_checkbox.xml | 12 ++ ...ragment_lists_catalog_item_with_switch.xml | 13 ++ .../layout/test_list_row_view_toggleables.xml | 26 +++ .../check_ListRowViewWithSwitch_xml.png | Bin 0 -> 27966 bytes .../com/telefonica/mistica/badge/Badge.kt | 7 +- .../telefonica/mistica/list/ListRowView.kt | 185 +++++++++++++++--- .../mistica/list/ListRowViewWithCheckBox.kt | 88 +++++++++ .../mistica/list/ListRowViewWithSwitch.kt | 87 ++++++++ .../com/telefonica/mistica/list/README.md | 63 +++++- .../res/layout/list_row_checkbox_action.xml | 4 + .../strings_content_descriptions.xml | 3 +- .../strings_content_descriptions.xml | 3 +- .../strings_content_descriptions.xml | 3 +- .../strings_content_descriptions.xml | 3 +- .../src/main/res/values/attrs_components.xml | 2 + .../values/strings_content_descriptions.xml | 5 +- .../mistica/list/ListRowViewToggleableTest.kt | 162 +++++++++++++++ 19 files changed, 704 insertions(+), 56 deletions(-) create mode 100644 catalog/src/main/res/layout/screen_fragment_lists_catalog_item_with_checkbox.xml create mode 100644 catalog/src/main/res/layout/screen_fragment_lists_catalog_item_with_switch.xml create mode 100644 library-test-utils/src/main/res/layout/test_list_row_view_toggleables.xml create mode 100644 library/screenshots/check_ListRowViewWithSwitch_xml.png create mode 100644 library/src/main/java/com/telefonica/mistica/list/ListRowViewWithCheckBox.kt create mode 100644 library/src/main/java/com/telefonica/mistica/list/ListRowViewWithSwitch.kt create mode 100644 library/src/main/res/layout/list_row_checkbox_action.xml create mode 100644 library/src/test/java/com/telefonica/mistica/list/ListRowViewToggleableTest.kt diff --git a/catalog/src/main/java/com/telefonica/mistica/catalog/ui/classic/components/ListsCatalogFragment.kt b/catalog/src/main/java/com/telefonica/mistica/catalog/ui/classic/components/ListsCatalogFragment.kt index 2fb2c7efa..0c816c8ff 100644 --- a/catalog/src/main/java/com/telefonica/mistica/catalog/ui/classic/components/ListsCatalogFragment.kt +++ b/catalog/src/main/java/com/telefonica/mistica/catalog/ui/classic/components/ListsCatalogFragment.kt @@ -10,7 +10,10 @@ import android.widget.Toast import androidx.appcompat.content.res.AppCompatResources import androidx.fragment.app.Fragment import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.RecyclerView.ViewHolder import com.telefonica.mistica.catalog.R +import com.telefonica.mistica.catalog.ui.classic.components.ListsCatalogFragment.ToggleableListAdapter.ViewType.CHECKBOX +import com.telefonica.mistica.catalog.ui.classic.components.ListsCatalogFragment.ToggleableListAdapter.ViewType.SWITCH import com.telefonica.mistica.list.ListRowView import com.telefonica.mistica.list.ListRowView.AssetType import com.telefonica.mistica.list.ListRowView.Companion.TYPE_IMAGE @@ -20,6 +23,8 @@ import com.telefonica.mistica.list.ListRowView.Companion.TYPE_IMAGE_7_10 import com.telefonica.mistica.list.ListRowView.Companion.TYPE_IMAGE_ROUNDED import com.telefonica.mistica.list.ListRowView.Companion.TYPE_LARGE_ICON import com.telefonica.mistica.list.ListRowView.Companion.TYPE_SMALL_ICON +import com.telefonica.mistica.list.ListRowViewWithCheckBox +import com.telefonica.mistica.list.ListRowViewWithSwitch import com.telefonica.mistica.list.MisticaRecyclerView import com.telefonica.mistica.list.model.ImageDimensions import com.telefonica.mistica.tag.TagStyle @@ -53,6 +58,9 @@ class ListsCatalogFragment : Fragment() { val clickableRow: MisticaRecyclerView = view.findViewById(R.id.clickable_list) clickableRow.adapter = ClickableListAdapter() + + val toggleableRow: MisticaRecyclerView = view.findViewById(R.id.toggleable_list) + toggleableRow.adapter = ToggleableListAdapter() } class ListAdapter( @@ -368,7 +376,7 @@ class ListsCatalogFragment : Fragment() { withHeadline = true, withInverseBackground = withInverseBackground, withUrlIcon = "fail_image_url", - withErrorIcon = AppCompatResources.getDrawable(it.context, R.drawable.ic_error) + withErrorIcon = AppCompatResources.getDrawable(it.context, R.drawable.ic_error) ) }, { @@ -380,7 +388,7 @@ class ListsCatalogFragment : Fragment() { withHeadline = true, withInverseBackground = withInverseBackground, withUrlIcon = "fail_image_url", - withErrorIcon = AppCompatResources.getDrawable(it.context, R.drawable.ic_error) + withErrorIcon = AppCompatResources.getDrawable(it.context, R.drawable.ic_error) ) }, { @@ -392,7 +400,7 @@ class ListsCatalogFragment : Fragment() { withHeadline = true, withInverseBackground = withInverseBackground, withUrlIcon = "fail_image_url", - withErrorIcon = AppCompatResources.getDrawable(it.context, R.drawable.ic_error) + withErrorIcon = AppCompatResources.getDrawable(it.context, R.drawable.ic_error) ) }, { @@ -404,7 +412,7 @@ class ListsCatalogFragment : Fragment() { withHeadline = true, withInverseBackground = withInverseBackground, withUrlIcon = "fail_image_url", - withErrorIcon = AppCompatResources.getDrawable(it.context, R.drawable.ic_error) + withErrorIcon = AppCompatResources.getDrawable(it.context, R.drawable.ic_error) ) }, { @@ -416,7 +424,7 @@ class ListsCatalogFragment : Fragment() { withHeadline = true, withInverseBackground = withInverseBackground, withUrlIcon = "fail_image_url", - withErrorIcon = AppCompatResources.getDrawable(it.context, R.drawable.ic_error) + withErrorIcon = AppCompatResources.getDrawable(it.context, R.drawable.ic_error) ) }, { @@ -428,7 +436,7 @@ class ListsCatalogFragment : Fragment() { withHeadline = true, withInverseBackground = withInverseBackground, withUrlIcon = "fail_image_url", - withErrorIcon = AppCompatResources.getDrawable(it.context, R.drawable.ic_error) + withErrorIcon = AppCompatResources.getDrawable(it.context, R.drawable.ic_error) ) }, { @@ -482,18 +490,22 @@ class ListsCatalogFragment : Fragment() { withErrorIcon: Drawable? = null, ) { if (withHeadline) { - setHeadlineLayout(R.layout.list_row_tag_headline) + val headlineText = "Headline" + setHeadlineLayout(layoutRes = R.layout.list_row_tag_headline, contentDescription = headlineText) (getHeadline()!! as TagView).apply { setTagStyle(if (withInverseBackground) TYPE_INVERSE else withHeadlineStyle) - text = "Headline" + text = headlineText } } else { setHeadlineLayout(ListRowView.HEADLINE_NONE) } withTitleMaxLines?.let { setTitleMaxLines(it) } - setTitle(if (withLongTitle) "Title long enough to need more than 2 lines to show it, just for testing purposes." + - "More sample text just for testing purposes." else "Title") + setTitle( + if (withLongTitle) "Title long enough to need more than 2 lines to show it, just for testing purposes. " + + "More sample text just for testing purposes." + else "Title" + ) if (withTitleHeading == true) { setTitleHeading() } @@ -560,7 +572,7 @@ class ListsCatalogFragment : Fragment() { } } - class ListViewHolder(val rowView: ListRowView) : RecyclerView.ViewHolder(rowView) + class ListViewHolder(val rowView: ListRowView) : ViewHolder(rowView) class ClickableListAdapter : RecyclerView.Adapter() { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ListViewHolder { @@ -595,8 +607,45 @@ class ListsCatalogFragment : Fragment() { } } + class ListSwitchViewHolder(val rowView: ListRowViewWithSwitch) : ViewHolder(rowView) + class ListCheckBoxViewHolder(val rowView: ListRowViewWithCheckBox) : ViewHolder(rowView) + + class ToggleableListAdapter : RecyclerView.Adapter() { + + override fun getItemViewType(position: Int): Int = position + override fun getItemCount(): Int = 2 + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + return when (viewType) { + 0 -> ListSwitchViewHolder( + LayoutInflater.from(parent.context).inflate(R.layout.screen_fragment_lists_catalog_item_with_switch, parent, false) + as ListRowViewWithSwitch + ) + + else -> ListCheckBoxViewHolder( + LayoutInflater.from(parent.context).inflate(R.layout.screen_fragment_lists_catalog_item_with_checkbox, parent, false) + as ListRowViewWithCheckBox + ) + } + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + when (holder.itemViewType) { + SWITCH.viewType -> with((holder as ListSwitchViewHolder).rowView) { + setOnClickListener { changeSwitchState() } + } + CHECKBOX.viewType -> with((holder as ListCheckBoxViewHolder).rowView) { + setOnClickListener { changeCheckBoxState() } + } + } + } + + private enum class ViewType(val viewType: Int) { + SWITCH(0), CHECKBOX(1) + } + } + private companion object { const val IMAGE_URL = "https://www.fotoaparat.cz/imgs/a/26/2639/0n1wjdf0-cr-em13-09-1200x627x9.jpg" } - } diff --git a/catalog/src/main/res/layout/screen_fragment_lists_catalog.xml b/catalog/src/main/res/layout/screen_fragment_lists_catalog.xml index f43cf597e..05df17c43 100644 --- a/catalog/src/main/res/layout/screen_fragment_lists_catalog.xml +++ b/catalog/src/main/res/layout/screen_fragment_lists_catalog.xml @@ -90,5 +90,24 @@ app:listLayoutType="full_width" /> + + + + - \ No newline at end of file + diff --git a/catalog/src/main/res/layout/screen_fragment_lists_catalog_item_with_checkbox.xml b/catalog/src/main/res/layout/screen_fragment_lists_catalog_item_with_checkbox.xml new file mode 100644 index 000000000..d8090bea2 --- /dev/null +++ b/catalog/src/main/res/layout/screen_fragment_lists_catalog_item_with_checkbox.xml @@ -0,0 +1,12 @@ + diff --git a/catalog/src/main/res/layout/screen_fragment_lists_catalog_item_with_switch.xml b/catalog/src/main/res/layout/screen_fragment_lists_catalog_item_with_switch.xml new file mode 100644 index 000000000..9b0c0deea --- /dev/null +++ b/catalog/src/main/res/layout/screen_fragment_lists_catalog_item_with_switch.xml @@ -0,0 +1,13 @@ + diff --git a/library-test-utils/src/main/res/layout/test_list_row_view_toggleables.xml b/library-test-utils/src/main/res/layout/test_list_row_view_toggleables.xml new file mode 100644 index 000000000..7b7a6b3fb --- /dev/null +++ b/library-test-utils/src/main/res/layout/test_list_row_view_toggleables.xml @@ -0,0 +1,26 @@ + + + + + + + diff --git a/library/screenshots/check_ListRowViewWithSwitch_xml.png b/library/screenshots/check_ListRowViewWithSwitch_xml.png new file mode 100644 index 0000000000000000000000000000000000000000..bb1950026c138f0d9259e4e4a7a7e1ca1b4cd915 GIT binary patch literal 27966 zcmeFZXE>Z~_dYrZ5_ymWk*H~UkfOIqqD7R^1wlge-Wg>`AqWyRdhcPPjcy1*h%!2( zcf;rm#<1`4Jn#Gd{=dDC{c(TT$D2?0kt3P=TI*WZTIX8VdEO!FYKqiUOjIBch+65n zoF)i#K@0>UwYo?F{LOncjb|W`J-L$HQ*AHf^-0@4hM|)_*%n=2W86L-))RH#OOS^G`MI-6d!jLubZ{d_3VBg!RhP27xnvQeZJ4 z(8UQ@Ir97obdj2b=KSf36e-L3!!0l3!ht_ewk|rIKLpHNojHF1HQ#L_Ie&Vm7(jOZ za3K;zdH!&b?Y}>C?uh^T=)ZA9421t4k^euX5zQ00IvNGsm%BN4aRk{)fIn*;!MwRcjVRUT?}pZ*OpKIja|ZHepd9WGJI=J=Qsd>J;9T zTNAYpk-?1Hdk!pP#~Z3j{?M?>n3gjf{N_p<#_EmyVLX-|jdyRaEP~4JN5W3b_73Wj zGD99O$;oc8GgIGifw*m_XQ13Yr`RWjp}s=4&2uX)DeUNOUbtk5J(muFxH!?fO@S(f zEcn=pGtXI2QVs{fu}-q|j4QXNOrN(q4l=d3Z>AQ?eEYJoKAYe1s-;JjUr)P{PbTsZ zqY?5*a|~fKTf~Ry^j|xmrSpD9P$|P z>+vRYRE-Hcoh~eLU`-ue<{^|I^xysKRd)G}_^IKQ(E$M!7QHFA7Nfgachh`LJ@v)U zUle-&BJzK~sOP4WtLa%*8`!MeElH76hS_g99tqEplD=qnR1q~uT_>1??=K43IqZTH z9*j45rgg`mn`MKukc4HkYxv~j6Vjw&P280bIJHUcpm+N5I z;A6z2v~6Bt?M;}S>DQ-bqmq zXixZp*q4G>3STSYOZeHz#qA&wjMoJ#$BsYiIKq`LRP0J$7^`J7*|uERZGKB51lzj-i|)Sm{Sna;7osJMSApZG%e-}) zN9(DB;?XOY7Ho*$UrZwX7ORg3KjBeIXR+LoTzTDyvBdsY%%?I!jLnv5IzBxlZpMJy zbeIa!;E4n=nqrCU9PO~@=-_vO4Nntn(Mmcsjv&xV*hQyXT9Qm#;dWC9X{g!$xB+Io ze8l3n+sfA0!-5l8h)QUCw2Ab>)CvO67NG$7#&TvnKg6tG_eN{30Z*$Ype(lD>>M&O z;y7;ViHh4P#fxpPciG3OP+iPVIx_23iQKrcZ{^mFy_<#~59q?m@RU)+@=V&cnBjSy+2tR3Umk!fUT!<@gP4D_@M% z0|(Q+PFp@#zk|@eFFF?|a41A!oT5$F&cZ zmhCNp+G^g~i|4U_hZy_YM!6)@9hIJ+pRa>^xBk*e*SUED1{azrH9^h}-c4LiKWsn1O*oyiT^AVFl<|1khVyq%?ABClxH>2;Z5w+bUR#06L&(YmPu3hyvKwhKg=_OAoQ8Lxi;nJ%AfwdZVA+3p2dR6@+M?-;&oy+ubKhs9v9}9{dG>MN;;Q#{tz$pc z?`027ixh7!@Ih%irwEjDi(&*U69@ULvf4j-#p_80{o5jS9@xXr=(hY*3Rc%nZ+Ems z&K;b?U8WbNWS-tk+Ayg zMHf%;127hyLs>e`gV*)R$W4Y}}L~nIN1l9FgnC%@y`#f(qx9+n|2rRmzC_BVu+TSyJ(w-;J zSm;5iBaq7#4=#c63#wa>yh1GJld_ZE56txr7A;lo=Ly49Z- zfNJ~v+Uz6$^@bd?WMUn0Y;8iaKY!GI##F=+!ZVd zR$E{d+mlJ+cnX0KP7+lRH&c?3;m}HmC>| zpH|U{sOqSUew5~nR@Z?fTXdDHB}juy!Zmght%<2MXLyg^;@pZIe54PfCrmOl4+Hx7vEF8j*f&TGH>eq6)Ca( zxz!__l+>KJqd2D5wrF@zHKq_dz*})V34wX4-GTGAcw5PXV3N|?l`ZbAx+Pq_iI(N9 zc0Ycnz14zPl#iBa_C_nL54=f%J!UFdaLjUbvK z%yw=sHF&5c^a6WFQv>@>Oo7?!UxJF!;EJ&q8VOSX$}^qz?{Dzv_ttCU;}fP* zd%MavqC2v()}98T|WlTf48EcON8iEI~pGo#(>hN}k)rSDJ>z$?jFz9%O4zBJAO; z#rM9-hyMkJzf(-}-6`nS`o^IEj%wQDSy_s4QVL>qf0 z>wex|okVGhuCh(uVv%lMPrF(4Qj~fx33Wne7W|cAckul3NPoY#^sc=3F&cBGtFy@r z0v(f*vUI#<-jZgR>5Jxv*;PkH6sLshe)$y#8Ov@v1mO1QQ zp{0QxZp+EI>+~w~-8n?(l7Bn5V3ue>yMM%vZ=1cWu%P7NUE7i%UG9rwu2Y$VN{;rL zy&&q}&(|8v_dnYD;5!tl7I8dYu5IQcBT=NQy0JtHppyT#;7a$(<}#rOe#Y(KhOlJ_ z%w%|5x~RozeVt&joE{W_RLy$*oJqBB)e-(%V1PSdX4$YVqp=HdHh36PP(C+ZfUm@- za6YPZ^FQ@(&;29&RYBU|1Uo49!q#rp)4xSY3G$6-3FZEv(7LFg zz7%Rd=O<*H0g?4R88LdTup4rStfF%qzoa>!%hQn=6oteZZVn3a0O03XE>&vRUCev) zsSmYUV>)b($06%&`}&%6F>jpTT^+Hh`jH-<-J7&PP4BIhq4kXP&fG|KS8ATEh=nCL zI@#Ob%3qltw0KrW1ahhXkc+l&!o3ZU1$r=6oYz)WsnUEcq;7BAtE|3$J~hXRT;J{} z|4nfSe|f)Sud0l&iceHp8~py+FTKbz!DUmvJzizyU8vQPfBbSXi71ybF4!QSxh@NP zGGws+&FxHLO{^dK=51-wtd#&mmxn0Cu;8BZluw_4A^L@;s9RB}`SkODyIa;F-{xZ2 zl%6ZuQF~_gZJ566H6T>hO!WLLg(Tu0J!^CX#mt{Z2;ncK@BXuLw}2M!{e96aS9l{w z$+u%PI=hCyBJqiVBY6?9^w7A-pt!@M{d<*TTl9XnkJ0&WG=p?Ew{Yds^M3GJcQ5`( zru|d76bUdcF@>^~{aN;{m`zFKi66F-IXLn~{qSeb+uGta*h--yCWnYqEIcTEipL=I z5xgFko|ZS{Hq~T^-7q6o9O&^s$G5&pNZY-HD&n{uXiSfvBn_b9#c6sY)sxUJKeF_P zL#UH%38k!sW%538BF3;HYs}A#eW~2lZu7Iv)`9)1-=;|aTQocXQ-oI`+b}j8py@wIP)9&p^A0B2~?S1&2 zqdDCFP3Jyd;XlTR3Tv~c9lx6U##C(D4_I<0s#DNWsO`O%i>GKx%OQWfhif66>C(g|A%Vj8sytt5_sZ`EUWA9fc8&ssj~G*(F}*?EhHKE& z`Wl44@uf8Csr;`RAD}<-OJ@;|gB*rW?;=U&Q~PXkKsd?nkEX0gb78OegsY`o8TmtC za8L|B1!~G7=G9?0mTc6&9p|GsVq6~Gd)>$gNUv7Ghxy6l#l*;huscrKF2@{w*2~B! z?EFDWF2HaTx;!vV!mo&BkB{?ICGJj2FArazmM+5vGYD%&#w_w}_FXo@!Te0n!;pFX zIhx|hL*hoMkTrhFj2*pGaW50;$icrEe{M!xFEDvX8!bV(gt#CQJhM?30Z>z(%60BhNE$t z9TPB;Q`bI^7r?Ft^SJL@WXvU&LF}Kz+*Z`xW|{)6rW^fGQw=`z6_&lJ;}C-J>P+){ zXO{|EWFBRMM8Lr+|TKFsIJf&$yfR2kM-t#NpLJ7QdMPPq_x1?+AA%hJ!d}&F?v6?8psRFl)^D5 zsZ0Xg8394>7y;BDDsy@`FXp$0QmkC?w}in@UQqd4v5&_etU^8BQokYj!VW+lUUWb& zpCiXG4eIc9yP}9PFZu_&_^EYLPA(9n20UqRXd{&EijU zx)r^9ZO%&q6s6dN&YoULuhtt${Lvfw{FX@!tuBQI2l~*Rf8|GB>LJQDj!2`TOAINL zZKnHtV(?lP%rL4Rd3nUJ*Y(%1^RbiFV)Mt zqq!Mab)yxOPsa(h*%YsD0v0Msijsdx)G+H(y7#gzDKzI@+DMQNo#FYqSMP`IQKST0G3u&arR$VIs zFL9alfxqfOwsgiSTwSbIf~I;sR%KS}`5L5$%Vn=roOufhhSwR|{^f1(1b~4kQ1C%9 zSLvzD23xdr)qB?AOt&?^tgLWD1jmlGQrj#o*tp7|%UpCap=0Dz$wxmp@@N>P8fpW=LtXzoNc*wt2IFH+WT8zlX4J}VOWVwzk7bHyi+W+cN%ADft z{ga&ZQukHbVyx{{NPPOiF0a@3OUVP;tBoXm$)_H*N^(Z(vs<$*l}$nHA6B$?aB$dK zgZp0_-zFa1BxU)Rs|=Myr+2?h`enTM&N+@B?u8Vf;ZeNNOfu=|LDOB$96AQf(h1Q7 zN*c~M(Tdd-t)9xH!C0b?tiD$xX;tUqh1)*`iH=hJP?qqBZ3dst#Jj_JQu;SBhb+6A zkZb5C8dbYecCt^XGg}lRM(uJ=mSaT+Qw$SaJVPhmXFihJ&kS^LPtUeZ$IC&6?R#%> zNC%F5sOi?OTrb}n+>Wd9kO$A}*yi(gd=<8nQ-YnjMI1m6m82PTi;WyTQwl{*43F|{ zJ@@9%#EJxvof^|zRL^bPIq|ifawIYLbpQnS*0q^hl%-YmR^hGp)DCVq2(=;Vgr$Tr zn0?_^AIV$d*c%wWzOWH)x{XQL3VSSAg7(bH?zA1hkFyriPoA8bjx_JeQ;{`%dfBj< z?ZEUMJMw4K4oB@RAmh7o3R}px|u7I?VqA_)vVuQ zgH)Z(#frOX`%U}^iYRe1hn`oR4{7;RRu1wqZX5Y9^|H>cPYlIc`4*_4et7SUk_=MZVmtIN3V4p65=wT161%Z{%vREeyf z4)*a<)ADHD-Y6a!&T~QGLYD@Aplh>J9|Oa9I$ET4ZYPd90PNgw>*NiOK=m6R&AmN{ zC`keMRnDJj*K{~gh+nTN>og>Z#zAa{8mb-ps|V%E58fY<2-Nf-MrR>s!w?N6#2 zH4fPuP3BZ;wok|_&s|aq`%CeuExp_>yt$%Au#GwzdvpF2wfpS&^K!=Pcbp8vJEb-Y zYmT!&_4HbIcZCdv3#zR5#|!Td$;wXi9ZT_5XBr0xaRyAaP>A+J9kh9{6E%;=5%z|A zm?F4>R-RfoCnS|6k4G!FRSDS@o$7ZmCZ?Js!k;MP2Ng1F`)rU1hWS^xt@c}WCkms6 ziw(1+eGXa|e@@GM_;h|H9$?%{>ySSZ)ZhG`a(*TVE3T1~M&-i2wbidI`0Hu7QZy$6 zjnjf^xkY#AbwOj6Vxu|{e^~6*E7$(MhldI?PHd5}P&rs#{B)UfG-e;43iWFVqDCc) zyJDcu6_R}!sce|V($~%J#QHMWSofBmaQ}T@zp{U__*(cv3$24TAukH*^4*}4mrZvL zxA21`R5zNGWn&r&=TMH5LsvOQc2Va`{Of)DPn<|-$VCVFnrYcSM9Cb^y!W~o@{ZOY zLc^SGW!YcPis>%39>}d;58(Zq29Q2dMi<5=<14n&kP#4w<+=K^cYkeUB&g8M^wSFi z%`avf6E&!|aOQ+~vRH+4zQ7Z>dHe^RO=_^!OcVjBDov4r%utF+ur4^!eGxEelrsKE zk8@0Cr{Vj3|k*Bl4>}_4uyw#`z4l`zs{aYG+KpYf)_$Lj?!3Q^p8x}I~3 z7VxhPoLea7ajH_bcu7Ah{UuvjR1*pXwGKuL(3|u+dZ5RmW=Efiee_ z=)y^Lp8oT@S)lgWmS8P17HjD{&+x0dAH7#|;^&^c*VSANVPi=oyO2-y&yF*Uyr1vO zkljXBIZhjoyuZ7*K&zqgx`m&?mh4K3nqd9bJAVK4_J3~)8%vtsfqs2*Ayhhh|HH-Y zyr|1gRy2Px&a42xtMVw6_un3n7Nei(lVSm)xZv`Y(ZQ8U-o}zM+i#tFs{>hiY5ukC zP^TX%_x}?ZUO5v8SVIetx)w%2tZ56>iF?0c+>-T5_aQ{VR3`SB9y0@dwy2Jr;eW zzu(CKkXuf6-_y!`B4dt}K6tse5DoFbU&so(&L6rgdb>LC-;UG_kPUZIeNg+RCCCbm zu&=!Jv_ok*eMnj8EQpjP(W*j{jAbBKHO$A+>W&^MOa8yoCWmknEvp1QR0+99Q)5SniS}KWsX;}P}R=!wf65cL~(69i(OgoDQU;&D4;CBxkxy`qS=cLk&SUe zbl#3xc{Hcug`@!62}&u7%GcE#YxMJ}$9y7X^>1p*shHNmx|56UxnjpFx3^*x*}St2 zxo+qLn4O*+ZEyFvlIZ|9LHVD+;a4Zw!RGm)BPOuB61e)yw${5#sV2)OeHks=1K&JQ z3udR{YDqFJ7QlN*TwAm&RMzA84d-qJ18JxeSohR7QAcBziItvN9x8`F36Gbm7EB4x3ODH6zuRjf!1 zje(<6yAaXVVnlV>z>@t(AOp+{RUa=aBLIJYtQ8>EPG znL=O*t)&yfCu~U^x^74!)oq0!RId|l%KJQh2n^J5M$u7*CqJHEh%fV>j)n{Bm)&Da zd<5NF@p3_h&~YIea6w7WwUwIb8}dl`{!)H=5m?Go*vZIu@47bzCp&y06tmUNkt2(x zN_3JRpPBfd9!Y~OOS(13J7bHk;fK{@EICyp5Dt@x-}IjCI@KR78KSo8HdPmpr^n27 z?rj(~3bD?Z-S1<)iX*hOJhiT)TPp-y6*}Hb9;XZ>xq!x}(?`Vf6r7h&8O(_pL=s*< zV}Ol^$O9$gCZ+h^rrO?=dVtyS-l{X4x?wq0+UJ`(eKUZvehNL+`bzQEDpcglt|q93 zJG)BCguB6<7pIzUo5k9>GAhI>E+B!IkN1?2Z_DR!vI68eeNMW^%!q%4d1TwzEPa)BN7 zFS63tCp<9wrLZ&qq~g28)DSTBv-?+uF-v0D{G{h}xGvn7l~xbs%VN`)x1YZ9JM9I4 zJOhKT-c^vxQV0+AaimZ1kqx}c(Vdf#dXvc`Q^jt%>| z_Wc^bOhMVd?>2uW-ko65o^9qsOpIpkBo|Z2NG_%W%trd*9#Al60E^@SYGr(lLY~pc zSKsYdEFmk&p^+bI`cn%vUb#8CPQ4m=QYd(O80oiAYSl+ADj#p`VO|eW?eX69!zsEh zH(tSlothl3Cq3M)8PAT1l#&o|_LmKs`ueTHWo_8rWA_!OYHHNzW{HuJh;nX8Nl7b0 zD^2y_)C)IB9b)!J0y6!Mk#Tru?PTCBxfsH3j7ilO+Rr$G8D1&tf`@h;w=p}0z|n2t zV_oA2%^r*{23c~XlB|1RDAI)L@nHmk+}Ks)R)Y%dfNH~5SY|U+c$kN&Ic~rzp(eqV z*~QQVtBc*8v3&Ds61oIqTGx{rH0b0e(fkO&eC1j~#G4R8b`GBXMy#z^TLi1*ytyp) zK&0Syt=D#!$8L3o!>$Df@{LjJZf~#Zdfpg@O~x;z0JR=f640szvgPTIzxrBz) zrXptJ>-;IuMIF5ndru(cIuMRq{T-NnebB?#HcLS5_V0s-V-2apt}3{r6b{$_4J z^|9g^x-T0%mC#W{f!Ub!v4PuGw_O(egP&^y#S;6YZ3L3yFm2QWSWm^!Ma`l z@uS-9T*GU4T?mNW%3#QdgeIQt#?k8ve?r|hV=>fbCCs}gnK3g=utw@@Zx2KN3vp2@ zIX4?*quN9r2q3Oi-wNhr-G9$Qb>FHZ^t1uU0dlu~LadQ|JGIIJpE7a%K9~l=Oi+Ys zd%l^u6;f~MN^4uWHuca+K#0(|Hs4sM{wYP|n z$0MO+eicK~x0$I-hu8Zd-=66$)Tyvg_qgee5Atv@r5}teI;Q`iRztEHC^$gU7yCdCOfc|Y?b}Afs3O#Y`?#_^iVE^pYz;E{;3k6A<=D-@d z+F8jnHsnniHdC{P?O;YPI)ILVKH!Zx84I(>z4uqk)6&e!@2$`_f=62ZB$V65d1b1= z(mWif)*A5}@I@?mh6(TT-HNP+Tln)p(BR4HHwp#1)a1io@#Euz;`htk^a5n*sztJl zsZT{5>r4ABLKyyD?7C-5(82V%D|tF*p)OYbm&qT{J2~;i1O9|+SF^EFNpP!YYyu(Y z^7Jnb@TXa>B$K084}M`yHgj-;K5I8(^x7BRF`Ki0An^lQ3VL0UrtQ({c3ZSnS+A5U z-Dj44b^9bkM^;mli0k*g9y;GgBN`mb?^&_^MXjfiC<+Vo6W6T>d=+$Od%lr7QX$&i z#{C+#V;|$|$CJQ4=)G6nD-$QXom52UN(EkAZS|LQCdMzui7v8`trX~5yvXUOX`T7L zTXlTuC*q(HV;}yFHyKbA0D|UgOW)m~dTwPH57b2^cX8^+sD&JX{0zVQhKTz#Hr2Nu zp2}14Zhc$p7opJjfpYHekeT3IytlnID@G1N@r^EBGcMLC%Q;fAz zf!1~q`jI&HzHGN5y=M%CG*m@AZGYxO#tn27RD5)RgT9=K*bF*O))rfr$*KzlW{_hI_24?!h%N0|b-O|7-=YjbSOFQ`oOJ%f@UEf8q; z;(*AwF21j^0y3nr&juB2uuPzI1DyiREYW|X)y`T*@8K$)1~t!2E|yz>(_Xd>aPHr` zu|KnNxC*^1&)kucZN@6KaL~Fv;oCJ+`^kTZl;tvGW6Y@9Jft{tapL}!*$N+I%6Xc1 zuX)2)D7X?BzQiFuEGbSz}0n{EW>2VL?eYD6SqO9oVj*W;DFp86yn9unoh+Z$v zq+XDkU|JJkAl%V<(gMyc(8}X5h8q9??Q=p1wx4tK{=eU^^>@JQ!CaeZ7}M2_o{@yL z1k<)Fk^F^oBp54sYqPt^s(&*hTltGvR8#_h_O&I%zT(8%b0Cz@p!+9l%Mck{m0-#< z{-j6NHQ*3G=u#=a5{8U%pnEavf~_#Hn$)^^^DkNtf(d^XT1)fnwJHLW+mjp{ zV@H=Wz>8u}3(x4wtmAI7vk>`oTv+pcB(nHlinE_4&fotj-~qQEJIBo&}7 zn!`ktzgvkfUy^&yb%5R=o(9kpJP|GcwSwyTtI^Gkggjr@Yf##`ATA^~ zmz#7p@&1W*S6p$36oAwLa-5PbbAqVj(>t&!P;gwqkO5X-C`MtVd2!^+Emp(C%*AA` zE4Ncd9l17)fOUbe@&RNYW2v1!?Hr?xU9ssalb)iZb`SZBys_@@ZM-*YJU?`6isC-( z_--`^ix^7Tnf{K_qjPnd@R=rv7>0ve=Z4WiLe$9%8T#dfT@5BEJ_Ustveg`s)Z3Dk zeoXJXRdrK*=V_9#2YP0~rA)B?0o(zZ*kI`T{O~Adhe9ky0H0~6``xQSKrD*_$+YV~ zr4*feZ|9|ar#R%+PoS8fiC_o>vJ(6U+%Wy3^`=ul4(|ho77|d8@ja`FLoN){zTT@b zu(3l||0&yHX0szs)NLijIWpvTbe;ljr1w3C&&TjJYu_w$$;$6}X~?sH!0pxqT)5XQ zhrRepA6)86TarV8q%k=)vCCcgs>l|`=I!?CLAAvEhdR@gIdGL}swQW^uENvtJ|3C&<^GgBm!L@{A8u2WAw z09foC}_`jZ(ke%n2g;bf0wRI?K+A^Wx8z-Pa_QbU!Z z^9KFXxSEK)emint;u;;744n~lEY=;bmwJgVHFS3&wkkR7!kug_e5?l%)PO(+fVxXs zExak5SyY}+uOt+hNwA5kFvyj8tlW!4E8? zdKia!tY9#q(~2bI{6~e};uV9o31%KL6Qy?zDly%Mqm@WjNssaS{2F8V8E0*biA-Vzd|Dgi)e=5E(w#Uau zmZgg>z_i-#IsjT^fvMj>kC73oUv#Q4XJ}1i^gjO>Z05Dl94ar9&#{J&$Ra{5r@w%pz)UybtVJ{t{!UI3Nc2H!ybeX`m-c>t4 zJ{DvbTa2z2X9AmB2m#6wRKRVVT>O-mB2oR~e3Ks_lUG_ytQsuSEsEbbC&wHD<_YP#s9clT(7mk8*oGs;E3pAyGqF>=bnHhIz0P15g0A? zXnSmh$a>^oTz&ewD=#)%;J|NeBHmavrrpFJ8;sg$z}q1vXxJoJ2v{(C^yo6coF27{ zt)|1N<6dOrUW3`% z4$q{d$Gb`Ul)_XPq3&|uN5mS2yIs56O37x;mjUr?P1m&6}VFUyiJ*N#4nqa0{S7ZQ=QjcIa(wU z3e!BBuc|VOr)O-d~6`&}8TNYaksQ(JHRpyCIs|(I1lE+mc zSR%LH%k)npX32-`MNIo@ys>5Zf(Mc5d609&)K?XI^cfvw2hqBsC@pkib___i7(_ji zH157?@j9_b9y@a7Qk*6iroePYT~@UNvN}9Nm(sVZbTF;;LO^@VO62qBlrbbe3QyDR zQhZ8-j-LPcOW7VFBWqMhDC^7q=Si**stG;_BHB6eyP zg3xsM8$x#3-TjtU2QcaIgRc4C)<>MOd)6h?F!t%gvER<3etY)k{zgX&!T%76H6im# zNqV^n0H=}TAcNs$neJ46|B+q@e|+HuG2Qwj4^uqABCPKw1wDZScr3x7*iN|I8-1`6 zK4fcKVB2e)#1Y+geeG}UnL~r4o7?^upDduc5o3~m<8tC7Ou_UY^_jT%|5Bgzi5=As za(laC>rnr9@mcO6QGCY#wCF$Lv)WETd{#VD6d#0EkbZgJ2#k~xLO#=g+N^HM?mm8N zy~q{=C|#u-v{$Mbl5015@L6^3Qdd=zA99WFoKlMTJR=Hq3jZb4vF|wEBZ|)o^?vQQ zuzhlIb^~yx?h9|TfAP_LmcvNXHX0tRdsc`Qg&C)A~H>yzXERCH|TXJWaOL|rN zm!$1nvk74&#~m&70*O3istZHJM6$+hH<+YYln-h+UQoTCmwM?yjFA0E^H+ubBEJ31 zS=Y2KvepBA-`%bF#NPPT4xnjk*KKsCzOQ3}{gFcJ;rq7Syx(UOJM^PNbBT>!_(Ez* z`7QrrtfONzsjhywVu9w^vPRCly$5!D`(_ojK5CktU|$M9T15jlUfo6&t?R|Y9z5pc zo?Q|}XR0B7=KB_<%iJjvXC8jJ{p;fmqBJq1zxC~pk8Tn&ocbY--~5pk;zGclE*UCTu?&)e_N0kC;Gs_4 z$K6caaZFn6DO2#0{vmEwpYe|cyJ8ht#PHXUpPX(7v-*^EN2P~C$89c#_e0cV>Sncm zNGeLlb-+8VACm=5M2{+j2V2rh zxblaOf&*b2L+|w4=85kAp=k^H{(oxPY);VW4pVt^)^1=M9{u>iYSjUw<>{W@n4DBf zkB_+k*NTy8f9c0!e||HZ?IUb|pPgfemZ-3~Zo4mOzRK*#fjC=oMgn3&w>e4q^$&>S zx~Y5>h7N?XPh{Go0ShuvIuB5=25roX5-h0c(^8+Y;)5?^cn!|{F-Oj1FZi5ZLFZWu zAn6q;MgkBcN0@65LuGc;J7hNwA|}0Ed*E1^7#FWKI32U0O59`nm z*Rx?c#JJk-%1WZ`PY4(ul{$_jY;e}0e+sd*Ps5r1cn4pu$dv3sPNwmsk#-Ojaqf8C z^j9rR?r8;zI!-%R_SM`qTsJG`~xE ztgsyai>)q*EAHkyV7hazuqqo;DW?eCY;Nb`*s%%G2_Hacua0Q>)`co$#gb zaRBkLEu0JtPt6^#d2_DdS@jAtt|My7zPKw zthMteXhMRE8+`n>4pp2@3AHl*2QD~jiB8F^ivt)2H$`wB?d`6%D`(-5$2 zQo=(PVPgskSSaJrZs2@jqW!+w%-HTaG|&v8O>T3q@noxHkJZL7;vB5ar&9 zEq=5kqP~~i*PB;T+MU1SIT&Jj4`HkA%{Oppo#VSFHylwga$rqOLfFVdN^*VG3;!f# z*YX^)vhKuM8h(wRBcY8V>Esg0{-AUiKc6&*U8~H|$dPDP-uS7A1kM7`f=Fy;LBvJB zdT#HC+94!tPVmvGgTCj=tKBMy9b zP9%>K`XBldzC~ZdJBVm2RIp2WDyI&)pEP}Z2g2yjA^ic>p2dOC_Lz^!aslpzvPZvW zJpKI+su;=1?UcFoLoYqkZ;6~N`(4`6#m9I&wni`eV3ZVe3mC5vp|U*FmiK~vdj7k& zeAaS#SGn6}UF|xi15unPEe?}quQ656;o`jvs+s>M{hm=chcZN! zAf-1U^~WOQC_1z7oqgSP%i@=r=JyELL}?`*@{xmaYoUTn-ar1vg=>M^NsZE*mehhQrVH2K`VpPugQso4aprNX$tT8T+5 zFe7Ef<^4Z@YC`PyVO?=y0BLIK67$Ouxe=cOtf3$S0D-Gjrfk58CfDVAim;z96YTMELX{?(aR-0&go}H$dFVIx2Z`iJvT7P%H ziqYI~oIXSBJ={K}64rlu@60+$H{}OpwY-)OZ}bHgzbjMgx&EQmc^cOdEutLmN0py( z)qA0VG8lZ~btkR1Y&mCR_!MCs9(Zo$xvs1g^w$tf)nsTBt zDYNsnbG8&o{$5(cEJX2~f!YskF1ddmk=EeR9W+@vIy_ z{kGgqj;j|LoyH?csenQzbdS1h2kX?t2z#pBJ&?@l@GN;pe^6hEQDHJXkniH!^b9p8 za~D_*{K#oR4oYVQx=Jm+*kY+r5%s~L5f9f&C9{Kb3E|I*`uSfuM6)89h7$w$`K)(@ zz(_yLw_ot*g?Q(ih`FR;djm*s#av*QE3qSm<0pR#%Qd4sPX8Qx{HcYdxU*O{JSZC- za$5iE#I!EUvbgXIpdYr1FSh3>z&GmrDs#-HYEQzAi=THBNqVUi{#Ok$2d2~BkxXVp zFHisOje$)Zt690R$G!n-leKYJW*`|pMRQM5kK`#V#EqP-pOg~VDbrO6?kDD5ytdU$ zAdopR$(+|mdr3**wM8fu>{$Ku%bb}JGq>`W7h=T6%1A88QKVtX%I&oI6@^Y8@} zf@AktQNMR_CXT{my)kErJsFdBHE%g$Wv|L3wfaXn5Swhfa&!9|r)s6Z*Ee42sbM}+ zf}Ye7?efq@Faeihr4_#4YaL8n@t>-HI`XLsSl5^8KC~vbs47RVh#xkwFVjum@>}(3 zAKob?qo+y(IYydiqN*aU)9-spXqkcei*wg3v-*7M7O;(_GUVi9~1vH7+w6mP9ZXqxkf# z^>+(IO{qTDXN=;gEaph9wBjBd8ClW$+S5Nz3}e5HTQ5_J&LIW$(GWWj*4Sd;u3J6y zN3045|5-e6zVhsjUU@DPLa7}<}X|j>y zLhQshtYABr*QwL~DbMl%@aT?HYV`#})q`P?wJX_DY;MHl?gUb;qffq^7J!n`XMmpA zzh41X`Iu0umpM2^L4~&eROW1{Ky5TTPr0O(+GNM zw}`gyejT=~G=Y|{%T0F&Uig$;mX`L!xV-A>ebf-JK1>c_#ho#|({K&@zC2_BNGB%3 zYxf&TIYBa~JB(%Jg${0%)|8KDJP%&!))x!XVHEKj8%{O~^zL-Ksr%XW1`sA@b;m^U&0>sprqc8!{ao@Ls5{EB5Mh=|8Of zMY|+^OxJI`nEEK(fhb*_zhvMg(i_HtC)(MvbNpqUI;!!)Y-iF}1tf%!A9m=1EeNE> zW9-n%d^De6Zo6Xp5U(kUzyomQ-6R=qP1hA`;DY7S+ODVoM^~u%V20*_xrU@1kqG=E z1*S#A(LDYNlLtc9sH-y&=^d1NgW9I)<*|fCC$s6pBf?|fvgWeKK zT<${v#%~t52~ah1Y}*m1xS~eG#n;oPru$b)-z;Tj+3ZvG7ii05$=PJRoZom4jO;-J19I~FgX*s7tCp6#7d-#Z z6Q9h_Tbf=oPg_3hsQ=>q^5IeS?zySIP5M4B(U(0g0aQhDmr|wJXg40Oy@okc8SPtN zvQ^taq?ElD(s=s4qA76nqTpqW{Uw#C*(!S4_HL!P%y#7Jr^iFo1~A7NHn%+k{$+om z2+;j1)s=M$oPFBs%JbA@E5Z(5izP6N1E>^%DyU6seT`VyK6iyyR2B82 zlhI-)<)N)-lQK4d*3M(zPg;0s|C2Dk@_DV|`!!KdD%z~yeip5`x{?I2)5Pn;uKc>$ z<%ONhXM(E$Z^~jGdLfGF_UwQfC1Uy5v{IceKs1yV6x4~?^kV&u)w6yJi~b7@>mn5& zO1@^Z=gd*W#J9|0pA%5^YkYe&H!ShX4F+TKT*9@i#v|>B(wUeSo%sclft{q^1cVZ8Y zrcJ{VZlqkmd~`@7a>6MYz6sIgHoUy$m&mBEe zc?Ftw$8IIpvDdokBp|%xww_@Y?q|aX#wG2|m@=b#U)EGlWfkaM=&}nb<3%}Z0(5!k z_f4*Ig!HOAz%vu$4P&#MhfRiaUJFfJcR)O{LGIQ3@WRfVVP9o+&PFQ@i; z;{d6S+~M^!9`EyR1sl|*!`R=4aMt|sHWE4*^m#|XCu1ipHS(|egiy8hyxtid`;OC= zz$N3JZ9|6?FR^#Mh@rF3uH_YZdqgETMszrxftpR0Hl-9~K2x6YJ0z&(*!ypRR+i+@ zBR(zQE(;inP-3Xlr9_&&NxFGxl)>2xR+_bm<&Y-Y`IN2Nx~gvId-p1*9$*U0rzv+xX_C~c`;7lx z*|G0nSxg4F77sURJg~e4pS7UPwLrWOb$q?s<4BC<%=&r0EtR+%FcRn0xV<=3J&9lD z4e;93Kv!bEdp!@o^sOYQL*(fE4|rnax79VV%QgY(sP<3Y-Iv8ez`L2R_2a(o|7aTv z`31w}KF4aM#`ozOvRktxNHna~mB}-{;v1*K4(MRzf%9kGuTy?n$FJw|NtYLge2F4D z&})8a%f$Jw-=mX^3S?1wF$?Db{N6p3`>Q_osvEAq;&9_n=js-nuv6F&n< za@Y#9e>jDX*rMMR))^^j&7U2;uXiEqN8g)#6kl8kpR3~4AeTM_BLAWQw|R5KLBEy^ z)3=}pgMYcy>|Gzd!K)u*G#o=XUsrRN=30P53UWr_$P8dAPOcV52uz=*SkMW;Sy7Lo z{NEX9>>2OofYLHsRrbFcyEn*P*jtn&YJ6M-#?WSbey2Qo6OJP@Rr{!X~jq5@q z)vR?V)|i1}{EniNisSn{_`sq`e66>&$F~_J1sb*RZF#us*4#!z3E)Hh=x>w?09aOI&!DHj&u8P zSI*q@sX_5pp=s56_g>_uOsJ?)l3_IFeEeuf@8X8jX%^ZAy9)X<5?`MYxp3RWH$zuYxPPJ2gUHYjq}$idhF z{$r>nJ4p!4=(Fh&>x-P5KUu-8X$cCiycdknm!c zQLOxePyel_30DmW@6e|X;u1FdjCG=%Une>EOsDY2q4UmKh{cNtyHeL|pbrkbx;m1^ z-BpLHE3Pd>6(x6CWNjw1lyS3TjUF(@Kmo*0ZHf}p%1tiN+%9l4?)qk4;$^ik&lbG& ziLfnh=+q*UpG&4wWYIROy8>DKaXw4;tVDPn2X4opE{}Y1$8Yfid_Nae_no`mbV(SQ z^H^>VpZna_uUl&_+YWF?V$o@8$3LBcNow)|l$PTeZ?+14o7!2dG!CrMcBQWA;BfSH z;ue5M(R8(4T=GVa#=~qLQ%%zJ+kZdye-dNE+2AQ3v6;S5)m%i8b-N6zz(DPTB@+9r!z_0$u&)&Xa#E5a8>)+P$L8y*}& z%sK(GX%&$I<^DY1xD)hqXZjy4ilGOwOI1;VW4MjQ3U;+rl!UsYDnN$)=sL<4Lapr- zw-f({XEXg1pLf`a=A6;BoreSzB4$5zzKB%}b9jXue>QM#sx!}ievD(-XbJ;#e;oJ6 zDhem)NN$unTB~ejEDBl0Ek}!^mbQ|}$7l6Iy;)<}x>W{2p89dusLu{k7t^p=akCQ7q4}q?SnUNZiM2ami`ehw8MY8Jg+h!ejlu* zE=?rs(5seVHJ)DHh~kr-vBd@J8;f=JiH4|5G0Z5!IB@mnV|Is{IYBG{)U^7?C*svf^r>4)VnOgO9^+JH$tvAF#KXo*f#gVuGn^Kc3f6jy1y5UnpLV+C3SIuX_U z-g^Zyaw@9StLnoLXrHyNR(k%3O*FHg#9XjW%-d5y8?rt3<~!Pz@x;A#q@vn4=|JmW zbfAxMqL$>W@nv(_n)4<@!a3Tj=;-8qV=cQe@|-5dF40czTzDBG!m{q%l8^g{JVM)+ z-rEm!3@elLSka*@f);tcq7yy5t~!jPR3>aPHg#sj1EN`@A6%ELq3WD#m8w zhEqckbGi~pTWK`K zkM`YI&X-nxEa!7fzH+?ez`M}6&3S%MCTz+{Cd~iu^w_wzoBIP=ip5EeSeJ>RtD?B& z#kvWCJTiAC7${u^Cf;r6OdRd^8+cV;*{da9R%*!&onVGLQkW$2yJhT@*>ZyHwn-_; z9`MvYwXirM!5c45YreNrgrq5q0&vLArY$)SB>c6LSjL|mhDFlYuJkc~2OUw@(l&rk zsLl~skK{RQI)dqN=v4i`gyJJv-~<|9L}@6Vyf1UY16K0SMu8^Zp#&--rq#C6Fi6Xa zTsW#_v1hi%lZ-(k-PNt8i*mvbmyBq&(M!A@w=1~{HKpUTpa&q$8Ba}#J!SOpnf{{! z$?S#9Hy0l4u^l6aE=h<>sGA{dj+}1nZWILc0sjho*(EH&SXbe6d{N%p`c@g;4F0_B z+Mch-v2JACElD&j{UwIjTJ+kH`n^>GRt5JGYgbLVR&8zt@786RFX!RM@AF(-;*)u6 z^SeGpO-Q%bb;b|6mIMs3W=oI~AAP7i9?xIZm*BK}(p!%c;?Zbo+P6O9z3;@sKo(7U z!vko~mQ)3dfGHhtu8xtA;9WH6p#hy6sETr#$WB+WC&AR~{K-~X9RQ$Fos^6A7@e!y zPfOCmkYIjZ4Sz2fpBq9mdO1(auX^NK>`&1$u`!%4N?_R`V58?BzhKp8 z9#q)nHgY{Wj1Lt1VSm8at9U-+`KO++w|=8>%uJm`!l5Bjiyb$TbGMo`|FUy(EQ<8y zU4Td5)Be8&*l!Ux5z8La}z^*6HJE$5Mo?y2K zUzeopp#Y8t+LZxV9FY4JZM(6SeU&JGF5Gj-4!PyXj*A(FU5R2U_O@(Q$8c*#GfJa2 zF}{saZm#KcaPN85H;QKAGv9Yt_QBfm_|c+#L*nerjk>KefoVSL+1otN_KSN(7DR1V zXf7nk4*Xk!B7kWtPI|L_^W+|D2Tt5Thksr)i7D>NiZZ9Rq{#Yn&_P43HreWrgw$}e zDaB?ThHArVVUcN;NUt1UKH~`1`5K_fxF35O*WI^O*f&lN{uHnekW0W8nbQN7O#r3y zw#iN$!U2=2;`*2kAiXjnd;mIs_$p@e!vKbA4X8uz#Zzi|KgMS2ZFFn7iH^ceXcJ=q z>Th3#QnaIHk4?|siLcX7(V6cC_&&!a`S(asRmQb1I~}fa4)(6t=GGSmxFP$i7;SW~ zmFVJqX$fd(@qCj3b$^zoNpMN(>eY^I3DPg15Mu!_WywPlX-!IPT^Hk;+U%jy`7r3OJzKMRC#I2Nh{ENK7Y1kHYQ&^ z-c!2m-7i=K#eso9hY(1vgu==kSqyLi5gQ^(2yuQNj^3mUygWL|cu`)_v-07AylvHA zK{tZ-?fU@ChV@rQ&ulWq?9Cq@xOX8(^$CO<4R^QDz^w*NGTDl%!ewSh5G;TI4Q8{X z0$O@X`?5m1R%Ds#YUcD{4SfU2|NQ#4!&UEszO;lgW}5dx%=Da!RqxyeTtf`QUHOts zc&~nqO{ZL!(6}~$9=P!BZMPnz2a=c+b{4nXd=Hh*l3*Sd{LTR9zj`Q*HHqB**Mx9_ zdk42M?%)udEc&#?OJ-k82Wc#TGBJ_kztWGbZ9!*ijn#w~(VgIADE;Ki6@%rq&GllYUnM~a zm>bfaE+qpe2g4eN?=C8etN1(X`Udo(cmoy|5k-~tlXb4F1r3L#3e-=wSN-P6S-Tv$SG!aEP8_hPHoP*`jg;_8-aIZc&Nn8 zWsgid1AcVhMyW*YX#!Z z+{fBBEVuU#UT-X06H^;zc5bONxb?zbe@``|j%osiVJ$m+fDoCn-VJ;G+E#lhKZ@?J z#$FF)FT+qka5KyHzbgnI3$;XF|LE2Vq%w5~%lZ9fENDZBA!!uE7&K@^ie7O7z+mEo zki5EIJ#*z!leJ%?pv>g>!Q}5rXXUJZF4qmyJ!$dBSdCsiPm*_<8OmG2Pl+lYWc?!fU6H5Q^ z9jgD)WcdPw75RrAOUmHx^`w8vQky4Bucn@`@g_{AENF5_a8exgBhz_oz#(=;A?$dI z`aQPAoiLPtFXeH9pXQ;tqRSrhv1KVsE5mdmc4cyvL8)9z^R^>GiSOe_C16P1(1X_E z_iV692e9g6(ZR+Q7Ofc``FX1m8hnJ@`XvVlYG{9s7R0Hukv(KWF)jcXj1cGgDxB%T zsX)N;9y$J7$HDx2)Uj8$ERd0fgY`>qCxEFMK5Ax)1}{nBGq$Q#EFdF_UYz|>Kv<84 zlPRs+C9OOTosW~$NQ@ewI_zo&5=#d>nE#&kqUKysi&ZtJvz|7Cc5C%0pxx@yZ371L zgg16PY-vomw^^FGV0zaSAT!aU^dYa-&%Fv97W}0fHkeoGf_i@y&7fIcNeqfz<`ul4 zzax3IGw7f$uQCQb%IDR+po^Bg5*kzj9tVE^`?vp!FyC0=9jdCs+gK P9o)pw!l3HB%bouLv#SV@ literal 0 HcmV?d00001 diff --git a/library/src/main/java/com/telefonica/mistica/badge/Badge.kt b/library/src/main/java/com/telefonica/mistica/badge/Badge.kt index 1ceac7b7e..155abdfea 100644 --- a/library/src/main/java/com/telefonica/mistica/badge/Badge.kt +++ b/library/src/main/java/com/telefonica/mistica/badge/Badge.kt @@ -88,10 +88,11 @@ object Badge { } } - private fun getDefaultBadgeDescription( + @JvmStatic + fun getDefaultBadgeDescription( anchor: View, - count: Int, - ) = if (count == 0) { + count: Int = NON_NUMERIC_BADGE, + ) = if (count == NON_NUMERIC_BADGE) { anchor.context.getString(R.string.badge_notification_description) } else { count.toString() diff --git a/library/src/main/java/com/telefonica/mistica/list/ListRowView.kt b/library/src/main/java/com/telefonica/mistica/list/ListRowView.kt index bc6341f27..225d902e9 100644 --- a/library/src/main/java/com/telefonica/mistica/list/ListRowView.kt +++ b/library/src/main/java/com/telefonica/mistica/list/ListRowView.kt @@ -5,17 +5,21 @@ import android.content.res.TypedArray import android.graphics.drawable.Drawable import android.text.TextUtils import android.util.AttributeSet +import android.util.Log import android.util.TypedValue import android.view.LayoutInflater import android.view.View +import android.widget.CheckBox import android.widget.FrameLayout import android.widget.ImageView import android.widget.LinearLayout +import android.widget.Switch import android.widget.TextView import androidx.annotation.DrawableRes import androidx.annotation.IntDef import androidx.annotation.LayoutRes import androidx.appcompat.content.res.AppCompatResources +import androidx.appcompat.widget.SwitchCompat import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb import androidx.constraintlayout.widget.ConstraintLayout @@ -27,6 +31,12 @@ import androidx.databinding.BindingMethod import androidx.databinding.BindingMethods import com.telefonica.mistica.R import com.telefonica.mistica.badge.Badge +import com.telefonica.mistica.list.ListRowView.ContentDescriptionKeys.DESCRIPTION +import com.telefonica.mistica.list.ListRowView.ContentDescriptionKeys.DETAIL +import com.telefonica.mistica.list.ListRowView.ContentDescriptionKeys.HEADLINE +import com.telefonica.mistica.list.ListRowView.ContentDescriptionKeys.RIGHT_SLOT +import com.telefonica.mistica.list.ListRowView.ContentDescriptionKeys.SUBTITLE +import com.telefonica.mistica.list.ListRowView.ContentDescriptionKeys.TITLE import com.telefonica.mistica.list.model.ImageDimensions import com.telefonica.mistica.util.convertDpToPx import com.telefonica.mistica.util.getMisticaThemeDrawableBuilder @@ -116,7 +126,7 @@ import com.telefonica.mistica.util.setAlpha method = "setAssetWidth" ), ) -class ListRowView @JvmOverloads constructor( +open class ListRowView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0, @@ -159,7 +169,7 @@ class ListRowView @JvmOverloads constructor( private val descriptionTextView: TextView private val badgeAnchor: View private val badgeAnchorContainer: FrameLayout - private val actionContainer: FrameLayout + protected val actionContainer: FrameLayout private var currentHeadlineLayoutRes: Int = HEADLINE_NONE private var currentActionLayoutRes: Int = ACTION_NONE @@ -168,6 +178,21 @@ class ListRowView @JvmOverloads constructor( private var assetWidth: Float = UNDEFINED private var cachedDefaultBackgroundType: Int = BackgroundType.TYPE_NORMAL + private var isViewInitialized = false + + // Important! This map builds a sentence for a11y according to Mística order definition. Do not modify the order arbitrarily, check: + // https://www.figma.com/design/Be8QB9onmHunKCCAkIBAVr/%F0%9F%94%B8-Lists-Specs?node-id=4615-10711&t=rHgrWciayIn0NP4V-4 + private val contentDescriptionValues = linkedSetOf( + ContentDescriptionInfo(key = TITLE, description = null), + ContentDescriptionInfo(key = HEADLINE, description = null), + ContentDescriptionInfo(key = SUBTITLE, description = null), + ContentDescriptionInfo(key = DESCRIPTION, description = null), + ContentDescriptionInfo(key = DETAIL, description = null), + ContentDescriptionInfo(key = RIGHT_SLOT, description = null), + ) + private data class ContentDescriptionInfo(val key: ContentDescriptionKeys, var description: String?) + private var headlineContentDescription: String? = null + init { LayoutInflater.from(context).inflate(R.layout.list_row_item, this, true) @@ -195,24 +220,43 @@ class ListRowView @JvmOverloads constructor( defStyleAttr, 0 ) + + // Title setTitleMaxLines(styledAttrs.getInteger(R.styleable.ListRowView_listRowTitleMaxLines, -1)) - styledAttrs.getText(R.styleable.ListRowView_listRowTitle)?.let { setTitle(it) } - styledAttrs.getResourceId( + styledAttrs.getText(R.styleable.ListRowView_listRowTitle)?.let { + setTitle(it) + } + styledAttrs.getBoolean( + R.styleable.ListRowView_listRowIsTitleHeading, + false + ) + .takeIf { it } + ?.let { setTitleHeading() } + + // Headline + val headlineResId: Int = styledAttrs.getResourceId( R.styleable.ListRowView_listRowHeadlineLayout, TypedValue.TYPE_NULL ) - .takeIf { it != TypedValue.TYPE_NULL } - .let { setHeadlineLayout(it ?: HEADLINE_NONE) } - setHeadlineVisible( - styledAttrs.getBoolean( - R.styleable.ListRowView_listRowHeadlineVisible, - currentHeadlineLayoutRes != HEADLINE_NONE - ) + val headlineVisible: Boolean = styledAttrs.getBoolean( + R.styleable.ListRowView_listRowHeadlineVisible, + currentHeadlineLayoutRes != HEADLINE_NONE + ) + setHeadlineLayout( + layoutRes = headlineResId.takeIf { it != TypedValue.TYPE_NULL } ?: HEADLINE_NONE, + contentDescription = styledAttrs.getString(R.styleable.ListRowView_listRowHeadlineContentDescription) ) + setHeadlineVisible(headlineVisible) + + // Subtitle setSubtitleMaxLines(styledAttrs.getInteger(R.styleable.ListRowView_listRowSubtitleMaxLines, -1)) setSubtitle(styledAttrs.getText(R.styleable.ListRowView_listRowSubtitle)) + + // Description setDescriptionMaxLines(styledAttrs.getInteger(R.styleable.ListRowView_listRowDescriptionMaxLines, -1)) setDescription(styledAttrs.getText(R.styleable.ListRowView_listRowDescription)) + + // Style val isBoxed = styledAttrs.getBoolean(R.styleable.ListRowView_listRowIsBoxed, false) val backgroundTypeDefaultValue = if (isBoxed) { BackgroundType.TYPE_BOXED @@ -226,6 +270,8 @@ class ListRowView @JvmOverloads constructor( backgroundTypeDefaultValue ) ) + + // Left image setAssetHeight( styledAttrs.getDimension( R.styleable.ListRowView_listRowAssetHeight, @@ -248,23 +294,47 @@ class ListRowView @JvmOverloads constructor( .takeIf { it != TypedValue.TYPE_NULL } ?.let { AppCompatResources.getDrawable(context, it) } setAssetDrawable(assetDrawable) - setBadgeInitialState(styledAttrs) + // Action layout (right slot) styledAttrs.getResourceId( R.styleable.ListRowView_listRowActionLayout, TypedValue.TYPE_NULL ) .takeIf { it != TypedValue.TYPE_NULL } - .let { setActionLayout(it ?: ACTION_NONE) } + .let { + setActionLayout( + layoutRes = it ?: ACTION_NONE, + contentDescription = styledAttrs.getString(R.styleable.ListRowView_listRowActionContentDescription) + ) + } + + // Badge + setBadgeInitialState(styledAttrs) - styledAttrs.getBoolean( - R.styleable.ListRowView_listRowIsTitleHeading, - false - ) - .takeIf { it } - ?.let { setTitleHeading() } + finishInit(styledAttrs) + } + } - styledAttrs.recycle() + private fun finishInit(styledAttrs: TypedArray) { + styledAttrs.recycle() + isViewInitialized = true + recalculateContentDescription() + } + + private fun recalculateContentDescription(newContentDescriptionInfo: ContentDescriptionInfo? = null) { + newContentDescriptionInfo?.let { newInfo -> + contentDescriptionValues.find { it.key == newInfo.key }?.description = newInfo.description + } + + // Refresh only when all values has been initialized. + if (isViewInitialized) { + val contentDescriptionBuilder = StringBuilder() + + contentDescriptionValues.filter { it.description != null }.forEach { + contentDescriptionBuilder.append("${it.description}. ") + } + + this@ListRowView.contentDescription = contentDescriptionBuilder } } @@ -392,6 +462,7 @@ class ListRowView @JvmOverloads constructor( fun setTitle(text: CharSequence?) { titleTextView.text = text recalculateTitleBottomConstraints() + recalculateContentDescription(ContentDescriptionInfo(key = TITLE, description = text?.toString())) } fun setTitleMaxLines(maxLines: Int) { @@ -425,13 +496,16 @@ class ListRowView @JvmOverloads constructor( background = when (type) { BackgroundType.TYPE_BOXED -> AppCompatResources.getDrawable(context, R.drawable.boxed_list_row_background) + BackgroundType.TYPE_BOXED_INVERSE -> context.getMisticaThemeDrawableBuilder(R.attr.drawableBackgroundBrand) .withCornerRadius() .withRipple() .get() + BackgroundType.TYPE_NORMAL -> AppCompatResources.getDrawable(context, R.drawable.list_row_background) + else -> AppCompatResources.getDrawable(context, R.drawable.list_row_background) } @@ -480,12 +554,13 @@ class ListRowView @JvmOverloads constructor( headlineContainer.visibility = if (visible) View.VISIBLE else View.GONE recalculateTitleBottomConstraints() recalculateAssetPosition() + updateHeadlineContentDescription(headlineContentDescription) } fun getHeadline(): View? = headlineContainer.getChildAt(0) - fun setHeadlineLayout(@LayoutRes layoutRes: Int = HEADLINE_NONE) { + fun setHeadlineLayout(@LayoutRes layoutRes: Int = HEADLINE_NONE, contentDescription: String? = null) { if (currentHeadlineLayoutRes != layoutRes) { headlineContainer.removeAllViews() if (layoutRes != HEADLINE_NONE) { @@ -496,12 +571,30 @@ class ListRowView @JvmOverloads constructor( } currentHeadlineLayoutRes = layoutRes } + updateHeadlineContentDescription(contentDescription) + } + + private fun updateHeadlineContentDescription(contentDescription: String?) { + if (headlineContentDescription != contentDescription) { + headlineContentDescription = contentDescription + + recalculateContentDescription( + ContentDescriptionInfo( + key = HEADLINE, + description = when (headlineContainer.visibility) { + VISIBLE -> headlineContentDescription + else -> null + } + ) + ) + } } fun setSubtitle(text: CharSequence?) { subtitleTextView.setTextAndVisibility(text) recalculateTitleBottomConstraints() recalculateAssetPosition() + recalculateContentDescription(ContentDescriptionInfo(key = SUBTITLE, description = text?.toString())) } fun setSubtitleMaxLines(maxLines: Int) { @@ -515,6 +608,7 @@ class ListRowView @JvmOverloads constructor( descriptionTextView.setTextAndVisibility(text) recalculateTitleBottomConstraints() recalculateAssetPosition() + recalculateContentDescription(ContentDescriptionInfo(key = DESCRIPTION, description = text?.toString())) } fun setDescriptionMaxLines(maxLines: Int) { @@ -524,19 +618,37 @@ class ListRowView @JvmOverloads constructor( } } - fun setActionLayout(@LayoutRes layoutRes: Int = ACTION_NONE) { + open fun setActionLayout(@LayoutRes layoutRes: Int = ACTION_NONE) { + setActionLayout(layoutRes, null) + } + + open fun setActionLayout(@LayoutRes layoutRes: Int = ACTION_NONE, contentDescription: String? = null) { if (currentActionLayoutRes != layoutRes) { actionContainer.removeAllViews() if (layoutRes != ACTION_NONE) { - LayoutInflater.from(context).inflate(layoutRes, actionContainer, true) + val actionView = LayoutInflater.from(context).inflate(layoutRes, actionContainer, true) + checkToggleableWarning(actionView) actionContainer.visibility = View.VISIBLE + recalculateContentDescription(ContentDescriptionInfo(key = RIGHT_SLOT, description = contentDescription)) } else { actionContainer.visibility = View.GONE + recalculateContentDescription(ContentDescriptionInfo(key = RIGHT_SLOT, description = null)) } currentActionLayoutRes = layoutRes } } + private fun checkToggleableWarning(actionView: View) { + if (actionView is Switch || actionView is SwitchCompat) Log.w( + "ListRowView", + "Using ListRowView with a Switch component can lead to a bad accessibility behavior. Consider using ListRowViewWithSwitch instead." + ) + if (actionView is CheckBox) Log.w( + "ListRowView", + "Using ListRowView with a CheckBox component can lead to a bad accessibility behavior. Consider using ListRowViewWithCheckBox instead." + ) + } + fun setBadge(show: Boolean, withBadgeDescription: String? = null) { if (show) { showNonNumericBadge(withBadgeDescription) @@ -553,18 +665,18 @@ class ListRowView @JvmOverloads constructor( } } - fun getActionView(): View? = - actionContainer.getChildAt(0) + open fun getActionView(): View? = actionContainer.getChildAt(0) override fun setEnabled(enabled: Boolean) { super.setEnabled(enabled) titleTextView.isEnabled = enabled descriptionTextView.isEnabled = enabled - getActionView()?.isEnabled = enabled + actionContainer.getChildAt(0)?.isEnabled = enabled + isClickable = enabled setAlpha(enabled) } - fun delegateClickOnActionView() { + open fun delegateClickOnActionView() { setOnClickListener { getActionView()?.performClick() } @@ -573,14 +685,25 @@ class ListRowView @JvmOverloads constructor( private fun showNonNumericBadge(withBadgeDescription: String?) { Badge.removeBadge(badgeAnchor) badgeAnchorContainer.visibility = View.VISIBLE - Badge.showBadgeIn(badgeAnchor, badgeAnchorContainer, withBadgeDescription) + Badge.showBadgeIn(badgeAnchor, badgeAnchorContainer) + + // Important! Recalculate contentDescription after the Badge has been built + recalculateContentDescription(ContentDescriptionInfo(key = DETAIL, description = withBadgeDescription ?: Badge.getDefaultBadgeDescription(badgeAnchor))) } private fun showNumericBadge(count: Int, withBadgeDescription: String?) { Badge.removeBadge(badgeAnchor) badgeAnchorContainer.visibility = View.VISIBLE badgeAnchorContainer.setBackgroundColor(Color.Transparent.toArgb()) - Badge.showNumericBadgeIn(badgeAnchor, badgeAnchorContainer, count, withBadgeDescription) + Badge.showNumericBadgeIn(badgeAnchor, badgeAnchorContainer, count) + + // Important! Recalculate contentDescription after the Badge has been built + recalculateContentDescription( + ContentDescriptionInfo( + key = DETAIL, + description = withBadgeDescription ?: Badge.getDefaultBadgeDescription(badgeAnchor, count) + ) + ) } private fun hideBadge() { @@ -661,6 +784,10 @@ class ListRowView @JvmOverloads constructor( } } + private enum class ContentDescriptionKeys { + TITLE, HEADLINE, SUBTITLE, DESCRIPTION, DETAIL, RIGHT_SLOT + } + companion object { private const val BADGE_GONE = 0 private const val UNDEFINED = -1f diff --git a/library/src/main/java/com/telefonica/mistica/list/ListRowViewWithCheckBox.kt b/library/src/main/java/com/telefonica/mistica/list/ListRowViewWithCheckBox.kt new file mode 100644 index 000000000..c15075a9a --- /dev/null +++ b/library/src/main/java/com/telefonica/mistica/list/ListRowViewWithCheckBox.kt @@ -0,0 +1,88 @@ +package com.telefonica.mistica.list + +import android.content.Context +import android.util.AttributeSet +import android.util.Log +import android.view.LayoutInflater +import android.view.View +import android.view.accessibility.AccessibilityEvent +import android.view.accessibility.AccessibilityNodeInfo +import android.widget.CheckBox +import androidx.appcompat.widget.AppCompatCheckBox +import androidx.core.view.ViewCompat +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat +import com.telefonica.mistica.R + +class ListRowViewWithCheckBox @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0, +) : ListRowView(context, attrs, defStyleAttr) { + + private val checkBoxCompat: AppCompatCheckBox + + init { + LayoutInflater.from(context).inflate(R.layout.list_row_checkbox_action, actionContainer, true) + actionContainer.visibility = View.VISIBLE + + checkBoxCompat = actionContainer.getChildAt(0) as AppCompatCheckBox + + checkBoxCompat.isClickable = false + checkBoxCompat.importantForAccessibility = IMPORTANT_FOR_ACCESSIBILITY_NO + + ViewCompat.setStateDescription(this, ViewCompat.getStateDescription(checkBoxCompat)) + } + + // Utility Switch method to be used by the implementation + fun changeCheckBoxState(newState: Boolean? = null) { + checkBoxCompat.isChecked = newState ?: !checkBoxCompat.isChecked + ViewCompat.setStateDescription(this, ViewCompat.getStateDescription(checkBoxCompat)) + this@ListRowViewWithCheckBox.announceForAccessibility(ViewCompat.getStateDescription(this)) + } + + fun isCheckBoxChecked() = checkBoxCompat.isChecked + + // Accessibility configuration + override fun onInitializeAccessibilityEvent(event: AccessibilityEvent?) { + super.onInitializeAccessibilityEvent(event) + event?.className = CheckBox::class.java.name + } + + override fun onInitializeAccessibilityNodeInfo(info: AccessibilityNodeInfo?) { + super.onInitializeAccessibilityNodeInfo(info) + info?.apply { + className = CheckBox::class.java.name + isCheckable = true + addAction( + AccessibilityNodeInfo.AccessibilityAction( + AccessibilityNodeInfoCompat.ACTION_CLICK, + resources.getString(R.string.toggle_action_click) + ) + ) + } + } + + // Restrict Action Layout usage + override fun setActionLayout(layoutRes: Int, contentDescription: String?) { + if (layoutRes != ACTION_NONE) { + throw IllegalArgumentException( + "You cannot add a custom Action Layout for ListRowViewWithCheckBox since this component is managing a CheckBox in that place.\n" + + "If you want to add a custom Action Layout you can use the generic ListRowView component instead." + ) + } + } + + override fun getActionView(): View? { + throw IllegalStateException( + "You cannot access to the custom Action Layout for ListRowViewWithCheckBox since this component is managing a CheckBox in that place.\n" + + "If you want to manage the Switch state, you can use the utility methods provided by the component." + ) + } + + override fun delegateClickOnActionView() { + Log.w( + "ListRowViewWithCheckBox", + "Delegate click on ActionView has no effect for ListRowViewWithCheckBox component since the component itself is intended to perform CheckBox changes." + ) + } +} diff --git a/library/src/main/java/com/telefonica/mistica/list/ListRowViewWithSwitch.kt b/library/src/main/java/com/telefonica/mistica/list/ListRowViewWithSwitch.kt new file mode 100644 index 000000000..98c61edb3 --- /dev/null +++ b/library/src/main/java/com/telefonica/mistica/list/ListRowViewWithSwitch.kt @@ -0,0 +1,87 @@ +package com.telefonica.mistica.list + +import android.content.Context +import android.util.AttributeSet +import android.util.Log +import android.view.LayoutInflater +import android.view.View +import android.view.accessibility.AccessibilityEvent +import android.view.accessibility.AccessibilityNodeInfo +import android.widget.Switch +import androidx.appcompat.widget.SwitchCompat +import androidx.core.view.ViewCompat +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat +import com.telefonica.mistica.R + +class ListRowViewWithSwitch @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0, +) : ListRowView(context, attrs, defStyleAttr) { + + private val switchCompat: SwitchCompat + + init { + LayoutInflater.from(context).inflate(R.layout.list_row_switch_action, actionContainer, true) + actionContainer.visibility = View.VISIBLE + + switchCompat = actionContainer.getChildAt(0) as SwitchCompat + + switchCompat.isClickable = false + switchCompat.importantForAccessibility = IMPORTANT_FOR_ACCESSIBILITY_NO + + ViewCompat.setStateDescription(this, ViewCompat.getStateDescription(switchCompat)) + } + + // Utility Switch method to be used by the implementation + fun changeSwitchState(newState: Boolean? = null) { + switchCompat.isChecked = newState ?: !switchCompat.isChecked + ViewCompat.setStateDescription(this, ViewCompat.getStateDescription(switchCompat)) + } + + fun isSwitchChecked() = switchCompat.isChecked + + // Accessibility configuration + override fun onInitializeAccessibilityEvent(event: AccessibilityEvent?) { + super.onInitializeAccessibilityEvent(event) + event?.className = Switch::class.java.name + } + + override fun onInitializeAccessibilityNodeInfo(info: AccessibilityNodeInfo?) { + super.onInitializeAccessibilityNodeInfo(info) + info?.apply { + className = Switch::class.java.name + isCheckable = true + addAction( + AccessibilityNodeInfo.AccessibilityAction( + AccessibilityNodeInfoCompat.ACTION_CLICK, + resources.getString(R.string.toggle_action_click) + ) + ) + } + } + + // Restrict Action Layout usage + override fun setActionLayout(layoutRes: Int, contentDescription: String?) { + if (layoutRes != ACTION_NONE) { + throw IllegalArgumentException( + "You cannot add a custom Action Layout for ListRowViewWithSwitch since this component is managing a Switch in that place.\n" + + "If you want to add a custom Action Layout you can use the generic ListRowView component instead." + ) + } + } + + override fun getActionView(): View? { + throw IllegalStateException( + "You cannot access to the custom Action Layout for ListRowViewWithSwitch since this component is managing a Switch in that place.\n" + + "If you want to manage the Switch state, you can use the utility methods provided by the component." + ) + } + + override fun delegateClickOnActionView() { + Log.w( + "ListRowViewWithSwitch", + "Delegate click on ActionView has no effect for ListRowViewWithSwitch component since the component itself is intended to perform Switch changes." + ) + } +} diff --git a/library/src/main/java/com/telefonica/mistica/list/README.md b/library/src/main/java/com/telefonica/mistica/list/README.md index b15ded5f9..b5d07d5fd 100644 --- a/library/src/main/java/com/telefonica/mistica/list/README.md +++ b/library/src/main/java/com/telefonica/mistica/list/README.md @@ -15,8 +15,9 @@ Implemented as a custom view, `com.telefonica.mistica.ListRowView` can be used i + - + @@ -32,11 +33,11 @@ Implemented as a custom view, `com.telefonica.mistica.ListRowView` can be used i - - + + - - + + @@ -46,6 +47,7 @@ Implemented as a custom view, `com.telefonica.mistica.ListRowView` can be used i + @@ -70,3 +72,54 @@ Also `com.telefonica.mistica.MisticaRecyclerView` is just a `RecyclerView` that ``` + +## Toggleables +There is two new sub-components from ListRowView to handle Action Layouts with toggleable views like Switch or CheckBox components. +Take a look to the new available sub-components: +* [ListRowViewWithSwitch](https://github.com/Telefonica/mistica-android/blob/ANDROID-14884_list_row_a11y/library/src/main/java/com/telefonica/mistica/list/ListRowViewWithSwitch.kt) +* [ListRowViewWithCheckBox](https://github.com/Telefonica/mistica-android/blob/ANDROID-14884_list_row_a11y/library/src/main/java/com/telefonica/mistica/list/ListRowViewWithCheckBox.kt) + +This two new sub-components have been created in order to handle the main accessibility actions according to Google standards with Toggleables views. So +please, consider replacing and start using these two new sub-components if you need to use a list element with a Switch or CheckBox view. + +### How to use the new sub-components? +First, place your code in XML or by code as you would normally do. +```xml + +``` + +Important! Do not try to alter the Action Layout attribute, since it is actually inflated and managed internally in order to handle a proper accessibility +configuration. It will thrown an IllegalArgumentException if you try to override this attribute: +```xml + + +``` +Second, use the utility exposed methods to manage the Switch component. Remember to set your custom onClickListener to handle the click events from the +component: +```kotlin +val listRowViewWithSwitch = findViewById(R.id.list_row_view_with_switch) +listRowViewWithSwitch.apply { + setOnClickListener { + listRowViewWithSwitch.changeSwitchAction() + } + val currentState = isSwitchChecked() + setTitle("My title") +} +``` + +Important! Do not try to access the Action Layout attribute, since it is actually inflated and managed internally in order to handle a proper accessibility +configuration. It will thrown an IllegalStateException if you try to access this attribute: +```kotlin +// Do not invoke this method +listRowViewWithSwitch.getActionView() +``` diff --git a/library/src/main/res/layout/list_row_checkbox_action.xml b/library/src/main/res/layout/list_row_checkbox_action.xml new file mode 100644 index 000000000..f47a98439 --- /dev/null +++ b/library/src/main/res/layout/list_row_checkbox_action.xml @@ -0,0 +1,4 @@ + + diff --git a/library/src/main/res/values-de/strings_content_descriptions.xml b/library/src/main/res/values-de/strings_content_descriptions.xml index 06bbef450..5db2d3776 100644 --- a/library/src/main/res/values-de/strings_content_descriptions.xml +++ b/library/src/main/res/values-de/strings_content_descriptions.xml @@ -2,4 +2,5 @@ Fenster schließen Neue Inhalte - \ No newline at end of file + Wechseln + diff --git a/library/src/main/res/values-en/strings_content_descriptions.xml b/library/src/main/res/values-en/strings_content_descriptions.xml index 360f97b35..3fa65e760 100644 --- a/library/src/main/res/values-en/strings_content_descriptions.xml +++ b/library/src/main/res/values-en/strings_content_descriptions.xml @@ -2,4 +2,5 @@ Close window New content - \ No newline at end of file + toggle + diff --git a/library/src/main/res/values-es/strings_content_descriptions.xml b/library/src/main/res/values-es/strings_content_descriptions.xml index c477d4cc0..025666d52 100644 --- a/library/src/main/res/values-es/strings_content_descriptions.xml +++ b/library/src/main/res/values-es/strings_content_descriptions.xml @@ -2,4 +2,5 @@ Cerrar ventana Contenido nuevo - \ No newline at end of file + alternar + diff --git a/library/src/main/res/values-pt/strings_content_descriptions.xml b/library/src/main/res/values-pt/strings_content_descriptions.xml index 3b0e0f97e..82a63b9f5 100644 --- a/library/src/main/res/values-pt/strings_content_descriptions.xml +++ b/library/src/main/res/values-pt/strings_content_descriptions.xml @@ -2,4 +2,5 @@ Fechar janela Novos conteúdos - \ No newline at end of file + alternar + diff --git a/library/src/main/res/values/attrs_components.xml b/library/src/main/res/values/attrs_components.xml index 56fcf249e..9bfb972c7 100755 --- a/library/src/main/res/values/attrs_components.xml +++ b/library/src/main/res/values/attrs_components.xml @@ -55,6 +55,7 @@ + @@ -88,6 +89,7 @@ + diff --git a/library/src/main/res/values/strings_content_descriptions.xml b/library/src/main/res/values/strings_content_descriptions.xml index 003b6cecd..025666d52 100644 --- a/library/src/main/res/values/strings_content_descriptions.xml +++ b/library/src/main/res/values/strings_content_descriptions.xml @@ -1,5 +1,6 @@ - Close window + Cerrar ventana Contenido nuevo - \ No newline at end of file + alternar + diff --git a/library/src/test/java/com/telefonica/mistica/list/ListRowViewToggleableTest.kt b/library/src/test/java/com/telefonica/mistica/list/ListRowViewToggleableTest.kt new file mode 100644 index 000000000..e6d2f32b9 --- /dev/null +++ b/library/src/test/java/com/telefonica/mistica/list/ListRowViewToggleableTest.kt @@ -0,0 +1,162 @@ +package com.telefonica.mistica.list + +import android.view.View.IMPORTANT_FOR_ACCESSIBILITY_NO +import android.widget.FrameLayout +import androidx.appcompat.widget.AppCompatCheckBox +import androidx.appcompat.widget.SwitchCompat +import androidx.core.view.ViewCompat +import androidx.test.espresso.Espresso +import androidx.test.espresso.matcher.ViewMatchers +import androidx.test.ext.junit.rules.activityScenarioRule +import com.telefonica.mistica.DummyActivity +import com.telefonica.mistica.R +import com.telefonica.mistica.testutils.ScreenshotsTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class ListRowViewToggleableTest : ScreenshotsTest() { + @get:Rule + val rule = activityScenarioRule() + + @Test + fun `check ListRowViewWithSwitch xml`() { + rule.scenario.onActivity { activity -> + val wrapper: FrameLayout = activity.findViewById(R.id.dummy_activity_wrapper) + activity.layoutInflater.inflate(R.layout.test_list_row_view_toggleables, wrapper, true) + + compareScreenshot(Espresso.onView(ViewMatchers.withId(R.id.dummy_activity_wrapper))) + } + } + + @Test(expected = IllegalArgumentException::class) + fun `check ListRowViewWithCheckBox adding custom action layout`() { + rule.scenario.onActivity { activity -> + val wrapper: FrameLayout = activity.findViewById(R.id.dummy_activity_wrapper) + activity.layoutInflater.inflate(R.layout.test_list_row_view_toggleables, wrapper, true) + + with(activity.findViewById(R.id.list_row_view_with_checkbox)) { + setActionLayout(R.layout.list_row_chevron_inverse_action) + } + } + } + + @Test(expected = IllegalStateException::class) + fun `check ListRowViewWithSwitch getting the custom action layout`() { + rule.scenario.onActivity { activity -> + val wrapper: FrameLayout = activity.findViewById(R.id.dummy_activity_wrapper) + activity.layoutInflater.inflate(R.layout.test_list_row_view_toggleables, wrapper, true) + + with(activity.findViewById(R.id.list_row_view_with_switch)) { + getActionView() + } + } + } + + @Test(expected = IllegalStateException::class) + fun `check ListRowViewWithCheckBox getting the custom action layout`() { + rule.scenario.onActivity { activity -> + val wrapper: FrameLayout = activity.findViewById(R.id.dummy_activity_wrapper) + activity.layoutInflater.inflate(R.layout.test_list_row_view_toggleables, wrapper, true) + + with(activity.findViewById(R.id.list_row_view_with_checkbox)) { + getActionView() + } + } + } + + @Test + fun `check ListRowViewWithSwitch state description`() { + rule.scenario.onActivity { activity -> + val wrapper: FrameLayout = activity.findViewById(R.id.dummy_activity_wrapper) + activity.layoutInflater.inflate(R.layout.test_list_row_view_toggleables, wrapper, true) + + with(activity.findViewById(R.id.list_row_view_with_switch)) { + val switchCompat = getActionContainer().getChildAt(0) as SwitchCompat + assertEquals(ViewCompat.getStateDescription(switchCompat), ViewCompat.getStateDescription(this)) + changeSwitchState() + assertEquals(ViewCompat.getStateDescription(switchCompat), ViewCompat.getStateDescription(this)) + } + } + } + + @Test + fun `check ListRowViewWithCheckBox state description`() { + rule.scenario.onActivity { activity -> + val wrapper: FrameLayout = activity.findViewById(R.id.dummy_activity_wrapper) + activity.layoutInflater.inflate(R.layout.test_list_row_view_toggleables, wrapper, true) + + with(activity.findViewById(R.id.list_row_view_with_checkbox)) { + val checkboxCompat = getActionContainer().getChildAt(0) as AppCompatCheckBox + assertEquals(ViewCompat.getStateDescription(checkboxCompat), ViewCompat.getStateDescription(this)) + changeCheckBoxState() + assertEquals(ViewCompat.getStateDescription(checkboxCompat), ViewCompat.getStateDescription(this)) + } + } + } + + @Test + fun `check ListRowViewWithSwitch action disabled`() { + rule.scenario.onActivity { activity -> + val wrapper: FrameLayout = activity.findViewById(R.id.dummy_activity_wrapper) + activity.layoutInflater.inflate(R.layout.test_list_row_view_toggleables, wrapper, true) + + with(activity.findViewById(R.id.list_row_view_with_switch)) { + val switchCompat = getActionContainer().getChildAt(0) as SwitchCompat + assertFalse(switchCompat.isClickable) + assertEquals(IMPORTANT_FOR_ACCESSIBILITY_NO, switchCompat.importantForAccessibility) + } + } + } + + @Test + fun `check ListRowViewWithCheckBox action disabled`() { + rule.scenario.onActivity { activity -> + val wrapper: FrameLayout = activity.findViewById(R.id.dummy_activity_wrapper) + activity.layoutInflater.inflate(R.layout.test_list_row_view_toggleables, wrapper, true) + + with(activity.findViewById(R.id.list_row_view_with_checkbox)) { + val checkboxCompat = getActionContainer().getChildAt(0) as AppCompatCheckBox + assertFalse(checkboxCompat.isClickable) + assertEquals(IMPORTANT_FOR_ACCESSIBILITY_NO, checkboxCompat.importantForAccessibility) + } + } + } + + @Test + fun `check ListRowViewWithSwitch changeSwitchAction`() { + rule.scenario.onActivity { activity -> + val wrapper: FrameLayout = activity.findViewById(R.id.dummy_activity_wrapper) + activity.layoutInflater.inflate(R.layout.test_list_row_view_toggleables, wrapper, true) + + with(activity.findViewById(R.id.list_row_view_with_switch)) { + val switchCompat = getActionContainer().getChildAt(0) as SwitchCompat + val initialState = switchCompat.isChecked + changeSwitchState() + assertNotEquals(initialState, switchCompat.isChecked) + } + } + } + + @Test + fun `check ListRowViewWithCheckBox changeSwitchAction`() { + rule.scenario.onActivity { activity -> + val wrapper: FrameLayout = activity.findViewById(R.id.dummy_activity_wrapper) + activity.layoutInflater.inflate(R.layout.test_list_row_view_toggleables, wrapper, true) + + with(activity.findViewById(R.id.list_row_view_with_checkbox)) { + val switchCompat = getActionContainer().getChildAt(0) as AppCompatCheckBox + val initialState = switchCompat.isChecked + changeCheckBoxState() + assertNotEquals(initialState, switchCompat.isChecked) + } + } + } + + private fun ListRowView.getActionContainer() = this.findViewById(R.id.row_action_container) +}