From a68f47501dd56bbbd0a7590aa7c52967de1c7e44 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Mon, 30 Mar 2026 20:11:43 -0700 Subject: [PATCH 01/24] inbox [nfc]: Remove a resolved TODO about topic-visibility icon color This is answered in the Figma; the color (matching the "@" icon) is correct: https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=16056-6333&m=dev --- lib/widgets/inbox.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/widgets/inbox.dart b/lib/widgets/inbox.dart index 9f6c40e0f..5aa13c5a0 100644 --- a/lib/widgets/inbox.dart +++ b/lib/widgets/inbox.dart @@ -551,7 +551,6 @@ class InboxTopicItem extends StatelessWidget { topic.displayName ?? store.realmEmptyTopicDisplayName))), const SizedBox(width: 12), if (hasMention) const _IconMarker(icon: ZulipIcons.at_sign), - // TODO(design) copies the "@" marker color; is there a better color? if (visibilityIcon != null) _IconMarker(icon: visibilityIcon), Padding(padding: const EdgeInsetsDirectional.only(end: 16), child: CounterBadge( From c10b0ddd3ccfb86ff771377938e717119d0d376b Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Mon, 30 Mar 2026 21:49:10 -0700 Subject: [PATCH 02/24] inbox [nfc]: Express rows' outer horizontal padding using Padding widgets --- lib/widgets/inbox.dart | 138 ++++++++++++++++++++--------------------- 1 file changed, 68 insertions(+), 70 deletions(-) diff --git a/lib/widgets/inbox.dart b/lib/widgets/inbox.dart index 5aa13c5a0..b421b2361 100644 --- a/lib/widgets/inbox.dart +++ b/lib/widgets/inbox.dart @@ -340,29 +340,28 @@ class InboxDmItem extends StatelessWidget { }, onLongPress: () => showDmActionSheet(context, narrow: narrow), child: ConstrainedBox(constraints: const BoxConstraints(minHeight: 34), - child: Row(crossAxisAlignment: CrossAxisAlignment.center, children: [ - const SizedBox(width: 63), - Expanded(child: Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: Text( - style: TextStyle( - fontSize: 17, - height: (20 / 17), - // TODO(design) check if this is the right variable - color: designVariables.labelMenuButton, - ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - title))), - const SizedBox(width: 12), - if (hasMention) const _IconMarker(icon: ZulipIcons.at_sign), - Padding(padding: const EdgeInsetsDirectional.only(end: 16), - child: CounterBadge( + child: Padding(padding: EdgeInsetsDirectional.fromSTEB(63, 0, 16, 0), + child: Row(crossAxisAlignment: CrossAxisAlignment.center, children: [ + Expanded(child: Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Text( + style: TextStyle( + fontSize: 17, + height: (20 / 17), + // TODO(design) check if this is the right variable + color: designVariables.labelMenuButton, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + title))), + const SizedBox(width: 12), + if (hasMention) const _IconMarker(icon: ZulipIcons.at_sign), + CounterBadge( // TODO(design) use CounterKind.quantity, following Figma kind: CounterBadgeKind.unread, channelIdForBackground: null, - count: count)), - ])))); + count: count), + ]))))); return Semantics(container: true, child: result); @@ -429,37 +428,37 @@ class InboxChannelHeaderItem extends StatelessWidget { // 40px min height. onTap: _onCollapseButtonTap, onLongPress: _onLongPress, - child: Row(crossAxisAlignment: CrossAxisAlignment.center, children: [ - Padding(padding: const EdgeInsets.all(10), - child: Icon(size: 20, color: designVariables.sectionCollapseIcon, - collapsed ? ZulipIcons.arrow_right : ZulipIcons.arrow_down)), - Icon(size: 18, - color: collapsed - ? swatch.iconOnPlainBackground - : swatch.iconOnBarBackground, - iconDataForStream(subscription)), - const SizedBox(width: 5), - Expanded(child: Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: Text( - style: TextStyle( - fontSize: 17, - height: (20 / 17), - // TODO(design) check if this is the right variable - color: designVariables.labelMenuButton, - ).merge(weightVariableTextStyle(context, wght: 600)), - maxLines: 1, - overflow: TextOverflow.ellipsis, - subscription.name))), - const SizedBox(width: 12), - if (hasMention) const _IconMarker(icon: ZulipIcons.at_sign), - Padding(padding: const EdgeInsetsDirectional.only(end: 16), - child: CounterBadge( + child: Padding(padding: EdgeInsetsDirectional.fromSTEB(0, 0, 16, 0), + child: Row(crossAxisAlignment: CrossAxisAlignment.center, children: [ + Padding(padding: const EdgeInsets.all(10), + child: Icon(size: 20, color: designVariables.sectionCollapseIcon, + collapsed ? ZulipIcons.arrow_right : ZulipIcons.arrow_down)), + Icon(size: 18, + color: collapsed + ? swatch.iconOnPlainBackground + : swatch.iconOnBarBackground, + iconDataForStream(subscription)), + const SizedBox(width: 5), + Expanded(child: Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Text( + style: TextStyle( + fontSize: 17, + height: (20 / 17), + // TODO(design) check if this is the right variable + color: designVariables.labelMenuButton, + ).merge(weightVariableTextStyle(context, wght: 600)), + maxLines: 1, + overflow: TextOverflow.ellipsis, + subscription.name))), + const SizedBox(width: 12), + if (hasMention) const _IconMarker(icon: ZulipIcons.at_sign), + CounterBadge( // TODO(design) use CounterKind.quantity, following Figma kind: CounterBadgeKind.unread, channelIdForBackground: subscription.streamId, - count: count)), - ]))); + count: count), + ])))); return Semantics(container: true, child: result); @@ -534,31 +533,30 @@ class InboxTopicItem extends StatelessWidget { topic: topic, someMessageIdInTopic: lastUnreadId), child: ConstrainedBox(constraints: const BoxConstraints(minHeight: 34), - child: Row(crossAxisAlignment: CrossAxisAlignment.center, children: [ - const SizedBox(width: 63), - Expanded(child: Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: Text( - style: TextStyle( - fontSize: 17, - height: (20 / 17), - fontStyle: topic.displayName == null ? FontStyle.italic : null, - // TODO(design) check if this is the right variable - color: designVariables.labelMenuButton, - ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - topic.displayName ?? store.realmEmptyTopicDisplayName))), - const SizedBox(width: 12), - if (hasMention) const _IconMarker(icon: ZulipIcons.at_sign), - if (visibilityIcon != null) _IconMarker(icon: visibilityIcon), - Padding(padding: const EdgeInsetsDirectional.only(end: 16), - child: CounterBadge( + child: Padding(padding: const EdgeInsetsDirectional.fromSTEB(63, 0, 16, 0), + child: Row(crossAxisAlignment: CrossAxisAlignment.center, children: [ + Expanded(child: Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Text( + style: TextStyle( + fontSize: 17, + height: (20 / 17), + fontStyle: topic.displayName == null ? FontStyle.italic : null, + // TODO(design) check if this is the right variable + color: designVariables.labelMenuButton, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + topic.displayName ?? store.realmEmptyTopicDisplayName))), + const SizedBox(width: 12), + if (hasMention) const _IconMarker(icon: ZulipIcons.at_sign), + if (visibilityIcon != null) _IconMarker(icon: visibilityIcon), + CounterBadge( // TODO(design) use CounterKind.quantity, following Figma kind: CounterBadgeKind.unread, channelIdForBackground: streamId, - count: count)), - ])))); + count: count), + ]))))); return Semantics(container: true, child: result); From ccc94fc48b811a58996ddd7fa40cd6e32f57eb3f Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Mon, 30 Mar 2026 20:46:33 -0700 Subject: [PATCH 03/24] recent dms [nfc]: Factor out helper widget for DM-conversation avatar/icon We'll use this in the inbox too, coming up. --- lib/widgets/recent_dm_conversations.dart | 69 ++++++++++++++++++------ 1 file changed, 52 insertions(+), 17 deletions(-) diff --git a/lib/widgets/recent_dm_conversations.dart b/lib/widgets/recent_dm_conversations.dart index b26560aa7..c5a487fd6 100644 --- a/lib/widgets/recent_dm_conversations.dart +++ b/lib/widgets/recent_dm_conversations.dart @@ -168,40 +168,29 @@ class RecentDmConversationsItem extends StatelessWidget { final int unreadCount; final OnDmSelectCallback onDmSelect; - static const double _avatarSize = 32; - @override Widget build(BuildContext context) { final store = PerAccountStoreWidget.of(context); final designVariables = DesignVariables.of(context); final InlineSpan title; - final Widget avatar; - int? userIdForPresence; switch (narrow.otherRecipientIds) { // TODO dedupe with DM items in [InboxPage] case []: title = TextSpan(text: store.selfUser.fullName, children: [ UserStatusEmoji.asWidgetSpan(userId: store.selfUserId, fontSize: 17, textScaler: MediaQuery.textScalerOf(context)), ]); - avatar = AvatarImage(userId: store.selfUserId, size: _avatarSize); case [var otherUserId]: title = TextSpan(text: store.userDisplayName(otherUserId), children: [ UserStatusEmoji.asWidgetSpan(userId: otherUserId, fontSize: 17, textScaler: MediaQuery.textScalerOf(context)), ]); - avatar = AvatarImage(userId: otherUserId, size: _avatarSize); - userIdForPresence = otherUserId; default: title = TextSpan( // TODO(i18n): List formatting, like you can do in JavaScript: // new Intl.ListFormat('ja').format(['Chris', 'Greg', 'Alya']) // // 'Chris、Greg、Alya' text: narrow.otherRecipientIds.map(store.userDisplayName).join(', ')); - avatar = ColoredBox(color: designVariables.avatarPlaceholderBg, - child: Center( - child: Icon(color: designVariables.avatarPlaceholderIcon, - ZulipIcons.group_dm))); } // TODO(design) check if this is the right variable @@ -214,12 +203,9 @@ class RecentDmConversationsItem extends StatelessWidget { child: ConstrainedBox(constraints: const BoxConstraints(minHeight: 48), child: Row(crossAxisAlignment: CrossAxisAlignment.center, children: [ Padding(padding: const EdgeInsetsDirectional.fromSTEB(12, 8, 0, 8), - child: AvatarShape( - size: _avatarSize, - borderRadius: 3, - backgroundColor: userIdForPresence != null ? backgroundColor : null, - userIdForPresence: userIdForPresence, - child: avatar)), + child: DmConversationAvatar( + narrow: narrow, + backgroundColor: backgroundColor)), const SizedBox(width: 8), Expanded(child: Padding( padding: const EdgeInsets.symmetric(vertical: 4), @@ -305,3 +291,52 @@ class _NewDmButtonState extends State<_NewDmButton> { ]))); } } + +/// An avatar or icon representing a DM conversation. +/// +/// See Figma: +/// https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=6556-31152&m=dev +class DmConversationAvatar extends StatelessWidget { + const DmConversationAvatar({ + super.key, + required this.narrow, + required this.backgroundColor, + }); + + final DmNarrow narrow; + + /// The color of the background this will be painted on. + /// + /// The presence circle uses this; see [PresenceCircle]. + final Color backgroundColor; + + static const double _avatarSize = 32; + + @override + Widget build(BuildContext context) { + final store = PerAccountStoreWidget.of(context); + final designVariables = DesignVariables.of(context); + + final Widget avatar; + int? userIdForPresence; + switch (narrow.otherRecipientIds) { + case []: + avatar = AvatarImage(userId: store.selfUserId, size: _avatarSize); + case [var otherUserId]: + avatar = AvatarImage(userId: otherUserId, size: _avatarSize); + userIdForPresence = otherUserId; + default: + avatar = ColoredBox(color: designVariables.avatarPlaceholderBg, + child: Center( + child: Icon(color: designVariables.avatarPlaceholderIcon, + ZulipIcons.group_dm))); + } + + return AvatarShape( + size: _avatarSize, + borderRadius: 3, + backgroundColor: userIdForPresence != null ? backgroundColor : null, + userIdForPresence: userIdForPresence, + child: avatar); + } +} From e22e73b7085993db0bd5bb93481dd66372826795 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Mon, 30 Mar 2026 21:04:57 -0700 Subject: [PATCH 04/24] recent dms: Use group-DM icons that distinguish number of users Fixes #350. As in Figma: https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=4905-34866&m=dev --- assets/icons/ZulipIcons.ttf | Bin 18308 -> 20420 bytes assets/icons/{group_dm.svg => group_dm_3.svg} | 0 assets/icons/group_dm_4.svg | 6 ++ assets/icons/group_dm_5.svg | 7 ++ assets/icons/group_dm_6.svg | 8 ++ assets/icons/group_dm_7.svg | 9 ++ assets/icons/group_dm_8.svg | 10 ++ assets/icons/group_dm_9.svg | 11 +++ lib/widgets/icons.dart | 92 +++++++++++------- lib/widgets/recent_dm_conversations.dart | 47 ++++++++- lib/widgets/theme.dart | 7 ++ .../widgets/recent_dm_conversations_test.dart | 2 +- 12 files changed, 157 insertions(+), 42 deletions(-) rename assets/icons/{group_dm.svg => group_dm_3.svg} (100%) create mode 100644 assets/icons/group_dm_4.svg create mode 100644 assets/icons/group_dm_5.svg create mode 100644 assets/icons/group_dm_6.svg create mode 100644 assets/icons/group_dm_7.svg create mode 100644 assets/icons/group_dm_8.svg create mode 100644 assets/icons/group_dm_9.svg diff --git a/assets/icons/ZulipIcons.ttf b/assets/icons/ZulipIcons.ttf index 3b486b6ceb87226c14a12983bc7fd60cd10cc273..6d8e1202a1f9871fb799081630f1ab86f366cd3c 100644 GIT binary patch delta 4327 zcmbtXdrX_x6+id00b__UHc5ampf-@K%iPqwyMPIPV_pWrD}XVNkgOqLNtz{W+SgXq z)UK(T)KwK}kNsy0muWWRI$eSDu) zT6G)Nx#!$-&OPsYKk>q=;_eNhk|3fIdX_A7C^~wy_l=S(9}aFJa(+bA={&KVUb^s?yFZ5Rp8;(rz_1DH z66o82h7+sjFBbmn(I)I&hmR|#&MbDNUtj!gEeupb;kT>li)-TBq76dc*Pl+WF7N(n zkDn<136avicIMpqr!MY80R0HSa_#DE@2(}80kYV-dFP$051tfDnOd=;UZXNZ1hp(*URt4bl(| zgSMbZhK9i}0Dl0#AdNsTigSZ}p6hBi9f(QOak5g8G@O)%<8WL^9`e&JxC~NEx=g@S z0`e(nCaD=32^z!s31AD^s2Yhi0S76L;KqT6AlVPwgWw#c1EAC}j2xBfm2j{dZl|FX zg_(Z*+)#-@eSj*U!x9dpp7x?9VT3&fqn!USm=A+W&>qOc$gB@*MAoi}4r4b?T{tt0 zGYM&b3TudxD%9e11T~0&iXs=umfV0eH3j!0$gdm5!jSW>2XqLolQ3w-*=pqJr$z)H z2Zt?i$R22MfSk1#beu+EVG>obg6;=xBh`g6bVKdy80|Oc7FEf1gc0u)!ihl=r5QNj zCgpQyY2OaqIjY%#o1!o6z@4XBbq98u>UQ8RP$hEUvW_AjZbK~^QKD5LDl-m3&h>eW zinsat1Qnx&Ey#MF&LY*zbd7FOE2(8L#{G_v~GKS9a)CR%TCzfYEMxAgl zEctwP5GRw=j&dfTJ%BwAToUehB>6PYdHBi!B=2>f< z%AMrljA9*>M%Z~2t%*y6IVp!@M{b{`&8`hi2W}AWCt!~!&wO=D<$&rwLn_qt>$FP~ z8OQXCT8s+m4mePqE<-iUK~GXSIP5a_2Jn?FLVbep!2=tFlwwla} z&8c?42>yg0CYd@2Y{$o-gRG(u`%)BGf#Xg}(Mej*_C9(9ZZmdidO9oQ4h;Dxpk0aI z0bC2h^`FJ#pTf;a(*hC;BBl|Ph97@^^as(Sz8ZuXM|M+q3pvjL1U-S{lW5yw=ArFU zSr^`!0G_TR)Qd0+Gs4zdG&4Jwnolo)Zmfaf(UDl9e`pv6`^Mtosbit!!1zRDa(Xc% z?CZ$1R4wKBtnbMv2|eTtaxeqjqQNY1s|Itx_)tnn0k>-~4~(z3gfwuc1`EKf#}Eau zj3EeEPK^*vu24Ube4W@v_;xuiYUo) z-Etk5r3xQl45IKU4y9JWT)u4OmofsxI2q;O&lfH+Z)qk`6>big<%TmN=JL!R(j zX6n46fw#QA=+bq-+4_lj=Q3}^Ve=`@g`YE0E%z;3%Pi#YRK;;J@uJ$Pzy2~Bw#xpT$>t@ZfO1W7EoWKW{Fk`RR4*ZEn;Fi&l`9CW}!gMd$j2kz8)#+2ynD{5jQ2hSNJx(T6@xFle=wiOAEJCw#ykXd+D4U^Xluk=CDBq3 zM-f@Rt?4|8-zcnkzOI^0qk6@kSwVa)+{OqsIaK-3@%=Fk8EaJ3;!1H*6`nl>qxXet zs{F%TALV)TZu3KZ@&ex9)JMBic|jUbMg2wc<~=<5CCnSge9>KVo$_wz7OJC9<0CSw zA+&($DcgM4EEHd;)*BBVd&Pr)!q-FZREsGwEslv9F^m7eQ(|7E#e!HAOJX^5ApFk( y`^wof&#a}ER#VLe*DbWGMr42F`NvQ{=F>)T1uf%)R;j920H#!#wP5+yTrwZ zWl?8+*pnuk4~q}W0zS-y7&LqG!7RSaEHhD~CYr?rV|<#!-}!ng_~47ZIX&k)-}&=9 z=R4oMH$K$PzM~n?0F>bjtZ0v>Mni8}j0!;O0*bPOse>Kqn~v=8)K53oX!{+hbCwFbW*eitL290zUtam`)9t^2 z5<7FeG`BeE&0PNA+7eLlJ`JwTXI8S>qSnbx57&(gnfdA3>wyj+)CgFPWEW2?*Wdc3 z3J5~oH(3Rp$c#cww*+&!d?<@Rbbh7FZy#ua>{6>IIdZ zaefwK6vKmhHc8Gj$?!qeo1m^pRWLwTsdz6_l$i;GD!7;X0_8!J{-ZA(Rw=u6n0pgg z;)OYni@1b?oU?r80Ya2gr4wF*X41A0I@N%9sV)qMj8_OxF+o35h`FTpv+6?x9HC#7 zYdPB(2UJd9tYo}82*2QSSam@S8`@cUgdQP0O-Y8)@bOd{7-k7?^A>!8+qjGSTDj)Y znhD6wFfwL?OdY3{gcn#r_DWZ&^1A}m!K(kuT0resR&b1EJp^C={C8iVay2G}q*8TZ zrk&;v-Pi@4APTpolu!!^1ZY97WS@3}9!3>4;+wySQ5Z1}O)(phU?LeQyJ}rNy~~^e zslS>Hlkpqjg|*Sc%2ky6<-C+}97C3uO-?w*CqHt#fk=P$b6@U^bWbKghBTe8U(kf{ zD1-ewKo8gAx{oR4+>&^VQj&=y3Pv}9=!Blv$W3VEBy{0%N-`PwAuIH+Ma>8=9h2*L zf|0^Fl;rXNs}XaJ2xWxigyp@Kf2YM*-+)Szg3a<-E>PmNj&F76&XkiDdl+CK9*Xqz_0ut& z(EX`VUo4s&3Ji~=bKg788eqHmv&k1kereLUNkQ_ANnY}oO&TDdG$~Hb&zqtU`Lsz9 z@?$3Tkk6RZM?Py(KVYyGP}EC)+@wMBIg|LU#Jouf@&%K0@xl7*!|u3O zHc|FvdAR%|XN&U<=kvHw_=LgO=@#~(5U(LA({w%f6EG?eQj!(_6zmRxQ Hvl8^*N#IiB diff --git a/assets/icons/group_dm.svg b/assets/icons/group_dm_3.svg similarity index 100% rename from assets/icons/group_dm.svg rename to assets/icons/group_dm_3.svg diff --git a/assets/icons/group_dm_4.svg b/assets/icons/group_dm_4.svg new file mode 100644 index 000000000..124c03504 --- /dev/null +++ b/assets/icons/group_dm_4.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/assets/icons/group_dm_5.svg b/assets/icons/group_dm_5.svg new file mode 100644 index 000000000..d253e81ce --- /dev/null +++ b/assets/icons/group_dm_5.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/assets/icons/group_dm_6.svg b/assets/icons/group_dm_6.svg new file mode 100644 index 000000000..714a3dbff --- /dev/null +++ b/assets/icons/group_dm_6.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/assets/icons/group_dm_7.svg b/assets/icons/group_dm_7.svg new file mode 100644 index 000000000..ed974bcc5 --- /dev/null +++ b/assets/icons/group_dm_7.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/assets/icons/group_dm_8.svg b/assets/icons/group_dm_8.svg new file mode 100644 index 000000000..f1fd701f4 --- /dev/null +++ b/assets/icons/group_dm_8.svg @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/assets/icons/group_dm_9.svg b/assets/icons/group_dm_9.svg new file mode 100644 index 000000000..a847d5485 --- /dev/null +++ b/assets/icons/group_dm_9.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/lib/widgets/icons.dart b/lib/widgets/icons.dart index b0708b1a1..da5260a63 100644 --- a/lib/widgets/icons.dart +++ b/lib/widgets/icons.dart @@ -100,113 +100,131 @@ abstract final class ZulipIcons { /// The Zulip custom icon "globe". static const IconData globe = IconData(0xf118, fontFamily: "Zulip Icons"); - /// The Zulip custom icon "group_dm". - static const IconData group_dm = IconData(0xf119, fontFamily: "Zulip Icons"); + /// The Zulip custom icon "group_dm_3". + static const IconData group_dm_3 = IconData(0xf119, fontFamily: "Zulip Icons"); + + /// The Zulip custom icon "group_dm_4". + static const IconData group_dm_4 = IconData(0xf11a, fontFamily: "Zulip Icons"); + + /// The Zulip custom icon "group_dm_5". + static const IconData group_dm_5 = IconData(0xf11b, fontFamily: "Zulip Icons"); + + /// The Zulip custom icon "group_dm_6". + static const IconData group_dm_6 = IconData(0xf11c, fontFamily: "Zulip Icons"); + + /// The Zulip custom icon "group_dm_7". + static const IconData group_dm_7 = IconData(0xf11d, fontFamily: "Zulip Icons"); + + /// The Zulip custom icon "group_dm_8". + static const IconData group_dm_8 = IconData(0xf11e, fontFamily: "Zulip Icons"); + + /// The Zulip custom icon "group_dm_9". + static const IconData group_dm_9 = IconData(0xf11f, fontFamily: "Zulip Icons"); /// The Zulip custom icon "hash_italic". - static const IconData hash_italic = IconData(0xf11a, fontFamily: "Zulip Icons"); + static const IconData hash_italic = IconData(0xf120, fontFamily: "Zulip Icons"); /// The Zulip custom icon "hash_sign". - static const IconData hash_sign = IconData(0xf11b, fontFamily: "Zulip Icons"); + static const IconData hash_sign = IconData(0xf121, fontFamily: "Zulip Icons"); /// The Zulip custom icon "image". - static const IconData image = IconData(0xf11c, fontFamily: "Zulip Icons"); + static const IconData image = IconData(0xf122, fontFamily: "Zulip Icons"); /// The Zulip custom icon "inbox". - static const IconData inbox = IconData(0xf11d, fontFamily: "Zulip Icons"); + static const IconData inbox = IconData(0xf123, fontFamily: "Zulip Icons"); /// The Zulip custom icon "info". - static const IconData info = IconData(0xf11e, fontFamily: "Zulip Icons"); + static const IconData info = IconData(0xf124, fontFamily: "Zulip Icons"); /// The Zulip custom icon "inherit". - static const IconData inherit = IconData(0xf11f, fontFamily: "Zulip Icons"); + static const IconData inherit = IconData(0xf125, fontFamily: "Zulip Icons"); /// The Zulip custom icon "language". - static const IconData language = IconData(0xf120, fontFamily: "Zulip Icons"); + static const IconData language = IconData(0xf126, fontFamily: "Zulip Icons"); /// The Zulip custom icon "link". - static const IconData link = IconData(0xf121, fontFamily: "Zulip Icons"); + static const IconData link = IconData(0xf127, fontFamily: "Zulip Icons"); /// The Zulip custom icon "lock". - static const IconData lock = IconData(0xf122, fontFamily: "Zulip Icons"); + static const IconData lock = IconData(0xf128, fontFamily: "Zulip Icons"); /// The Zulip custom icon "menu". - static const IconData menu = IconData(0xf123, fontFamily: "Zulip Icons"); + static const IconData menu = IconData(0xf129, fontFamily: "Zulip Icons"); /// The Zulip custom icon "message_checked". - static const IconData message_checked = IconData(0xf124, fontFamily: "Zulip Icons"); + static const IconData message_checked = IconData(0xf12a, fontFamily: "Zulip Icons"); /// The Zulip custom icon "message_feed". - static const IconData message_feed = IconData(0xf125, fontFamily: "Zulip Icons", matchTextDirection: true); + static const IconData message_feed = IconData(0xf12b, fontFamily: "Zulip Icons", matchTextDirection: true); /// The Zulip custom icon "more_horizontal". - static const IconData more_horizontal = IconData(0xf126, fontFamily: "Zulip Icons"); + static const IconData more_horizontal = IconData(0xf12c, fontFamily: "Zulip Icons"); /// The Zulip custom icon "mute". - static const IconData mute = IconData(0xf127, fontFamily: "Zulip Icons"); + static const IconData mute = IconData(0xf12d, fontFamily: "Zulip Icons"); /// The Zulip custom icon "person". - static const IconData person = IconData(0xf128, fontFamily: "Zulip Icons"); + static const IconData person = IconData(0xf12e, fontFamily: "Zulip Icons"); /// The Zulip custom icon "pin". - static const IconData pin = IconData(0xf129, fontFamily: "Zulip Icons"); + static const IconData pin = IconData(0xf12f, fontFamily: "Zulip Icons"); /// The Zulip custom icon "pin_remove". - static const IconData pin_remove = IconData(0xf12a, fontFamily: "Zulip Icons"); + static const IconData pin_remove = IconData(0xf130, fontFamily: "Zulip Icons"); /// The Zulip custom icon "plus". - static const IconData plus = IconData(0xf12b, fontFamily: "Zulip Icons"); + static const IconData plus = IconData(0xf131, fontFamily: "Zulip Icons"); /// The Zulip custom icon "read_receipts". - static const IconData read_receipts = IconData(0xf12c, fontFamily: "Zulip Icons"); + static const IconData read_receipts = IconData(0xf132, fontFamily: "Zulip Icons"); /// The Zulip custom icon "remove". - static const IconData remove = IconData(0xf12d, fontFamily: "Zulip Icons"); + static const IconData remove = IconData(0xf133, fontFamily: "Zulip Icons"); /// The Zulip custom icon "search". - static const IconData search = IconData(0xf12e, fontFamily: "Zulip Icons"); + static const IconData search = IconData(0xf134, fontFamily: "Zulip Icons"); /// The Zulip custom icon "see_who_reacted". - static const IconData see_who_reacted = IconData(0xf12f, fontFamily: "Zulip Icons"); + static const IconData see_who_reacted = IconData(0xf135, fontFamily: "Zulip Icons"); /// The Zulip custom icon "send". - static const IconData send = IconData(0xf130, fontFamily: "Zulip Icons", matchTextDirection: true); + static const IconData send = IconData(0xf136, fontFamily: "Zulip Icons", matchTextDirection: true); /// The Zulip custom icon "settings". - static const IconData settings = IconData(0xf131, fontFamily: "Zulip Icons"); + static const IconData settings = IconData(0xf137, fontFamily: "Zulip Icons"); /// The Zulip custom icon "share". - static const IconData share = IconData(0xf132, fontFamily: "Zulip Icons"); + static const IconData share = IconData(0xf138, fontFamily: "Zulip Icons"); /// The Zulip custom icon "share_ios". - static const IconData share_ios = IconData(0xf133, fontFamily: "Zulip Icons"); + static const IconData share_ios = IconData(0xf139, fontFamily: "Zulip Icons"); /// The Zulip custom icon "smile". - static const IconData smile = IconData(0xf134, fontFamily: "Zulip Icons"); + static const IconData smile = IconData(0xf13a, fontFamily: "Zulip Icons"); /// The Zulip custom icon "star". - static const IconData star = IconData(0xf135, fontFamily: "Zulip Icons"); + static const IconData star = IconData(0xf13b, fontFamily: "Zulip Icons"); /// The Zulip custom icon "star_filled". - static const IconData star_filled = IconData(0xf136, fontFamily: "Zulip Icons"); + static const IconData star_filled = IconData(0xf13c, fontFamily: "Zulip Icons"); /// The Zulip custom icon "three_person". - static const IconData three_person = IconData(0xf137, fontFamily: "Zulip Icons"); + static const IconData three_person = IconData(0xf13d, fontFamily: "Zulip Icons"); /// The Zulip custom icon "topic". - static const IconData topic = IconData(0xf138, fontFamily: "Zulip Icons"); + static const IconData topic = IconData(0xf13e, fontFamily: "Zulip Icons"); /// The Zulip custom icon "topics". - static const IconData topics = IconData(0xf139, fontFamily: "Zulip Icons", matchTextDirection: true); + static const IconData topics = IconData(0xf13f, fontFamily: "Zulip Icons", matchTextDirection: true); /// The Zulip custom icon "trash". - static const IconData trash = IconData(0xf13a, fontFamily: "Zulip Icons"); + static const IconData trash = IconData(0xf140, fontFamily: "Zulip Icons"); /// The Zulip custom icon "two_person". - static const IconData two_person = IconData(0xf13b, fontFamily: "Zulip Icons"); + static const IconData two_person = IconData(0xf141, fontFamily: "Zulip Icons"); /// The Zulip custom icon "unmute". - static const IconData unmute = IconData(0xf13c, fontFamily: "Zulip Icons"); + static const IconData unmute = IconData(0xf142, fontFamily: "Zulip Icons"); // END GENERATED ICON DATA } diff --git a/lib/widgets/recent_dm_conversations.dart b/lib/widgets/recent_dm_conversations.dart index c5a487fd6..4dcc754a3 100644 --- a/lib/widgets/recent_dm_conversations.dart +++ b/lib/widgets/recent_dm_conversations.dart @@ -312,6 +312,19 @@ class DmConversationAvatar extends StatelessWidget { static const double _avatarSize = 32; + static IconData? _iconForNumParticipants(int count) { + return switch (count) { + 3 => ZulipIcons.group_dm_3, + 4 => ZulipIcons.group_dm_4, + 5 => ZulipIcons.group_dm_5, + 6 => ZulipIcons.group_dm_6, + 7 => ZulipIcons.group_dm_7, + 8 => ZulipIcons.group_dm_8, + 9 => ZulipIcons.group_dm_9, + _ => null, + }; + } + @override Widget build(BuildContext context) { final store = PerAccountStoreWidget.of(context); @@ -326,10 +339,36 @@ class DmConversationAvatar extends StatelessWidget { avatar = AvatarImage(userId: otherUserId, size: _avatarSize); userIdForPresence = otherUserId; default: - avatar = ColoredBox(color: designVariables.avatarPlaceholderBg, - child: Center( - child: Icon(color: designVariables.avatarPlaceholderIcon, - ZulipIcons.group_dm))); + final allRecipientsCount = narrow.allRecipientIds.length; + if (allRecipientsCount < 10) { + final icon = _iconForNumParticipants(allRecipientsCount); + assert(icon != null); + avatar = Padding( + padding: EdgeInsets.all(4), + child: Icon( + size: 24, + color: designVariables.groupIcon, + icon)); + } else { + final isHundredPlus = allRecipientsCount > 99; + final double fontSize = isHundredPlus ? 14 : 17; + avatar = DecoratedBox( + decoration: BoxDecoration( + color: designVariables.groupIcon, + borderRadius: BorderRadius.circular(_avatarSize / 2), + ), + child: Center( + child: Text( + textScaler: TextScaler.noScaling, + textAlign: TextAlign.center, + style: TextStyle( + color: designVariables.bgMessageRegular, + fontSize: fontSize, + height: 19 / fontSize, + letterSpacing: proportionalLetterSpacing(context, 0.02, baseFontSize: fontSize), + ).merge(weightVariableTextStyle(context, wght: 500)), + isHundredPlus ? '99+' : allRecipientsCount.toString()))); + } } return AvatarShape( diff --git a/lib/widgets/theme.dart b/lib/widgets/theme.dart index 67958236c..e8ad75c79 100644 --- a/lib/widgets/theme.dart +++ b/lib/widgets/theme.dart @@ -195,6 +195,7 @@ class DesignVariables extends ThemeExtension { fabShadow: const Color(0xff2b0e8a).withValues(alpha: 0.4), folderText: const Color(0xff596680), foreground: const Color(0xff000000), + groupIcon: const Color(0xff7199fe), // blue/350 icon: const Color(0xff6159e1), iconSelected: const Color(0xff222222), labelCounterQuantity: const Color(0xff222222).withValues(alpha: 0.6), @@ -303,6 +304,7 @@ class DesignVariables extends ThemeExtension { fabShadow: const Color(0xff18171c), folderText: const Color(0xff8793ab), foreground: const Color(0xffffffff), + groupIcon: const Color(0xff84a8fd), // blue/300 icon: const Color(0xff7977fe), iconSelected: Colors.white.withValues(alpha: 0.8), labelCounterQuantity: const Color(0xffffffff).withValues(alpha: 0.7), @@ -420,6 +422,7 @@ class DesignVariables extends ThemeExtension { required this.fabShadow, required this.folderText, required this.foreground, + required this.groupIcon, required this.icon, required this.iconSelected, required this.labelCounterQuantity, @@ -529,6 +532,7 @@ class DesignVariables extends ThemeExtension { final Color fabShadow; final Color folderText; final Color foreground; + final Color groupIcon; final Color icon; final Color iconSelected; final Color labelCounterQuantity; @@ -632,6 +636,7 @@ class DesignVariables extends ThemeExtension { Color? fabShadow, Color? folderText, Color? foreground, + Color? groupIcon, Color? icon, Color? iconSelected, Color? labelCounterQuantity, @@ -730,6 +735,7 @@ class DesignVariables extends ThemeExtension { fabShadow: fabShadow ?? this.fabShadow, folderText: folderText ?? this.folderText, foreground: foreground ?? this.foreground, + groupIcon: groupIcon ?? this.groupIcon, icon: icon ?? this.icon, iconSelected: iconSelected ?? this.iconSelected, labelCounterQuantity: labelCounterQuantity ?? this.labelCounterQuantity, @@ -835,6 +841,7 @@ class DesignVariables extends ThemeExtension { fabShadow: Color.lerp(fabShadow, other.fabShadow, t)!, folderText: Color.lerp(folderText, other.folderText, t)!, foreground: Color.lerp(foreground, other.foreground, t)!, + groupIcon: Color.lerp(groupIcon, other.groupIcon, t)!, icon: Color.lerp(icon, other.icon, t)!, iconSelected: Color.lerp(iconSelected, other.iconSelected, t)!, labelCounterQuantity: Color.lerp(labelCounterQuantity, other.labelCounterQuantity, t)!, diff --git a/test/widgets/recent_dm_conversations_test.dart b/test/widgets/recent_dm_conversations_test.dart index b5fdd1021..935b23bfe 100644 --- a/test/widgets/recent_dm_conversations_test.dart +++ b/test/widgets/recent_dm_conversations_test.dart @@ -165,7 +165,7 @@ void main() { // TODO(#232): syntax like `check(find(…), findsOneWidget)` tester.widget(find.descendant( of: find.byWidget(shape.child), - matching: find.byIcon(ZulipIcons.group_dm), + matching: find.byIcon(ZulipIcons.group_dm_3), )); } } From 3c6b5d8a18a601a6e95101e32d32f4785478b0e7 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Mon, 30 Mar 2026 21:18:13 -0700 Subject: [PATCH 05/24] inbox: Use DM-conversation avatars in Inbox --- lib/widgets/inbox.dart | 12 ++++++++---- lib/widgets/recent_dm_conversations.dart | 5 ++++- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/lib/widgets/inbox.dart b/lib/widgets/inbox.dart index b421b2361..227a4fa44 100644 --- a/lib/widgets/inbox.dart +++ b/lib/widgets/inbox.dart @@ -10,6 +10,7 @@ import 'action_sheet.dart'; import 'icons.dart'; import 'message_list.dart'; import 'page.dart'; +import 'recent_dm_conversations.dart'; import 'sticky_header.dart'; import 'store.dart'; import 'text.dart'; @@ -331,19 +332,22 @@ class InboxDmItem extends StatelessWidget { _ => narrow.otherRecipientIds.map(store.userDisplayName).join(', '), }; + final backgroundColor = designVariables.background; // TODO(design) check if this is the right variable Widget result = Material( - color: designVariables.background, // TODO(design) check if this is the right variable + color: backgroundColor, child: InkWell( onTap: () { Navigator.push(context, MessageListPage.buildRoute(context: context, narrow: narrow)); }, onLongPress: () => showDmActionSheet(context, narrow: narrow), - child: ConstrainedBox(constraints: const BoxConstraints(minHeight: 34), - child: Padding(padding: EdgeInsetsDirectional.fromSTEB(63, 0, 16, 0), + child: ConstrainedBox(constraints: const BoxConstraints(minHeight: 44), + child: Padding(padding: EdgeInsetsDirectional.fromSTEB(25, 0, 16, 0), child: Row(crossAxisAlignment: CrossAxisAlignment.center, children: [ + DmConversationAvatar(narrow: narrow, backgroundColor: backgroundColor), + const SizedBox(width: 6), Expanded(child: Padding( - padding: const EdgeInsets.symmetric(vertical: 4), + padding: const EdgeInsets.symmetric(vertical: 3), child: Text( style: TextStyle( fontSize: 17, diff --git a/lib/widgets/recent_dm_conversations.dart b/lib/widgets/recent_dm_conversations.dart index 4dcc754a3..cbd06abe8 100644 --- a/lib/widgets/recent_dm_conversations.dart +++ b/lib/widgets/recent_dm_conversations.dart @@ -294,8 +294,11 @@ class _NewDmButtonState extends State<_NewDmButton> { /// An avatar or icon representing a DM conversation. /// -/// See Figma: +/// See Figma where this appears in the recent DMs page: /// https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=6556-31152&m=dev +/// +/// And the Inbox page: +/// https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=16056-6330&m=dev class DmConversationAvatar extends StatelessWidget { const DmConversationAvatar({ super.key, From fb8dcedfd9af908adf080b14c48639285ecefb37 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Mon, 30 Mar 2026 22:30:03 -0700 Subject: [PATCH 06/24] inbox: Put collapse/uncollapse icon after channel name, shrinking start margin Kind of like this Figma frame: https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=16056-6562&m=dev , which is current -- see discussion: https://chat.zulip.org/#narrow/channel/530-mobile-design/topic/channel.20folders.20in.20inbox.3A.20design/near/2421503 -- except with ad hoc margin adjustments to account for channel folders not yet being collapsible (#2220). Specifically the distance from screen edge to text start, in DM, channel, and topic items, has shrunk from 63px to 50px. --- assets/icons/ZulipIcons.ttf | Bin 20420 -> 20552 bytes assets/icons/chevron_up.svg | 3 + lib/widgets/icons.dart | 107 ++++++++++++++++++----------------- lib/widgets/inbox.dart | 46 ++++++++------- test/widgets/home_test.dart | 18 +++--- test/widgets/inbox_test.dart | 6 +- 6 files changed, 97 insertions(+), 83 deletions(-) create mode 100644 assets/icons/chevron_up.svg diff --git a/assets/icons/ZulipIcons.ttf b/assets/icons/ZulipIcons.ttf index 6d8e1202a1f9871fb799081630f1ab86f366cd3c..a80bf0570053e35b8acd67b4b62b825afa2ab72b 100644 GIT binary patch delta 2648 zcmZ`*U2Kz87=BLMt!vkA-G4X69Alu!-xyVb;9@ac5TjlZH3k>d8*kvk3!~uke*Nk&j(&N*_k8bp|Id5Q z>B0x<_C*zk0+5MQFrunC)V25Bit|?hwH-(=Z3}I#?!MHy5-@jCYG`CC8vFe0rw;&& zhx32#pFBQ3;hp;d$Z-Jmy%S^6(PO_XT&L_kqIQCe3}qVQ_$E;~F*P%5{qphOw0DC( z&QDH{ycm7++Y2WFhl>JtrlPYk^{T4nq?6y}hoVzs1z+s027D8Mp)NLkWai9w?_B`= z7XjmkXK${yk7*e&i#yi3gJ*x=qQ(+sYJdD1vQdf4xU14tm5Qia>Mz3$!>`73rn{#5 zX_2%T(qO|TxDmk&3~-={j~mVKpap)kBEV4-+R%;;j*TqRlEe}}jr;%~7ebWtP_Iq& zrLNW|6ug>gA51W7hJ%`6A04No0$X9HOBcMFOFvEdIUlBE5H*zWql@~zq()?*go!;% z>Vl8K^^mr6<~iDKBc~hdIEuSzq+1hr(m^5J_EX41Gp&5`DdMGg00oqh8408uPS(WD zu)Anf^4~}EZjSsY;=G%gwea1n)ov|zXzq4Wr-M3v&3>5gCIsUY^Io)s&I|CF@F#edwfxKCGdR zkIA`Nn|Lk@vXc`pv9;gE6`GRea5JAU!|~F92Lo8K3^#((Ww?V_xeRv*Wy^4*cqYCK zdzc0Oe-I-mW)4!;PUa)aP=-}(s$>;B+9J6)FZHQ_sru8$k;Eqsgx{D_CjqzY9fx9ARr zR7gY8{oQmPqC0UQfqA%WKFwKjRzkHh@25$J5Km2sXcpbdcP|Y}TRyq3ZCdxHoJ!uQ zt!CF}mb6lv*uqd#_9f8NIGaJ~evA2D+-pAP(TfgT_S6vTn}+&;CJfoG6Ad!Z2NQ z5n`rt`pc=`#*(+tsgI$Cc@|300S4R4UDd}$7)^X{d4pj@to7!B$ly?PcqH*z#+p)E z>I`}Pt?eDO(9-2|hxauF13kUXef^_}pRK3k5)gl;Tk_^_l-R0cfV4(OgmjyZK~i3f z8iq*gbVNycTWc65t=BO^Dmnt*)=V-9UbJ!M3k`Cui=ZGxNFV|)A=kFRPmJqmCEc&1 zos_q-h7QsLI@(AN>gXn&)X_pZrGfc(ad1e7k91mxn>3~)O!~5peWZtVG?56^if)G z+E?kd>F3Q(^9l1MORZ(ra>rU@y_UH%^C#Om+e5oMt0C+C?2PQ!vlkuXj+>4@a)LQu z=Vs;34IxTo}zD@mChT*&f@Np@{-Atdn?9QJSx4i?6Yxs z_EWg?_Z9*xZ}wEm`yF*TdG+E!>} Gwf+G=%%^bx delta 2527 zcmZ`*OH5Q}6#gzV%rG#(@N@vB7SUFVFGL0FlVM)W1BQWN26+twimm8WsYTQD8q+pi zkOn)AVbMfOyD*J$QC#$48f#pbrfE_)ZS2N{ZIi}L6VsKo{mz|#L=$B4-Sgjb&VSzj zf4O~0yLD01p#dnuS(vcJ8|`bqP&D@&ptS-Pb60e8bL{ip8o;`nTBE6{WcuR7oi%{1 ziSh@BkDfl%YWw9|z;PdFb4*So$4~u!>r3k1BX&%ZQJ|T}xxP*`PEJkFSib)6U)uYG zKIV=dPwh#5oO-{W2Aov*VJbP3*51~3P$=)Kk0qxjs=nIa4EP@cc{|g`PfqWesbc^^ z24I>$e|=5yxRC*~c)W1q>iGv-wed{7c37WBDH?GZziJk3i#Dv?&>rPo%lpIhk@<%C z$9zwI9Qm+g6FeBkH1gm;2Y+t9d(nvif(UWdfiSwz&9#X|TGCkJ=aV1e&xIc9dATnv z`=zdhPi^oSru{I(Y8Z|fhW&JGK?9mmOqVYB43`0#3Q(S)W(3=)5kN2Z_mi4XfLbQD zj?@J|gNu=NQSuUPhso)~%UtPh8tF6Copew|w*yr2(oB%Q3aa?19zrE`L?VH##tPQN z&9HlERPsMa^KPyJsHWV_%sTn>7`0o6-F%B-5BGF)Pr$IB;Ijh}ooap@U=2K6d6|pB zHn{r?Qwh57VSamQ%uTtx?dQ6Kt|K&P=H6Q7*^IRe-cOELkdPawB>_s-Yq<6!N(*sT z#mse(>jLQIEW=)^y~feLfGemm+Tmur35MgN#EU^XkS0CL9YWnQ++nO-hC6~)%Wy|g zuP?(+;)P|nV{kGDDQlGZNE_9>c4lLknCWJaZzK6Xx~)w&uCngzEV+-7e#oPF6L;|dk2SmI)EXI3HOiH#J;-UcvUDywky&<8Dy7T1cc>|l{L5J{ITHiC zMaCweaW)~9`N#Gi9a=I!7i_l<4h@ftCdas5yOOq|J-$G&tD81Dd;RXj z!H!5M*6)cAq%vC3xy-!+vu>A=&DZ3~zZ9`eVUV;%VTg3Q!Z0a6f(AxNcPfmM^2=-> zN!q3`Mk;y&FCj94ix|`8K|~30F6bfTvn=ot6AA&+LkdCC!wOxblM3CWM-;-OuPgMC z9#!Zhol?+yxj3faCq1s4zWpUZE_U$B&0(w2wTnDwl!+}3NmT<9pAEvha0 z#(vEHQ*lE{b;+5M`=!IB-#OYH^Nu@Zo65c@`>Xt&^1BuGig?All^ZGtDlb)yR?V(x zTyez-=bZClO>@noT3_w;y7s!eD?fP7Z{za>%kY6kN7)hUXWu(p`2Wt$;O)$8=<1?7 P{HJD`(bAdC-PQjCn5u{s diff --git a/assets/icons/chevron_up.svg b/assets/icons/chevron_up.svg new file mode 100644 index 000000000..16ae23871 --- /dev/null +++ b/assets/icons/chevron_up.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/lib/widgets/icons.dart b/lib/widgets/icons.dart index da5260a63..31d50cb50 100644 --- a/lib/widgets/icons.dart +++ b/lib/widgets/icons.dart @@ -70,161 +70,164 @@ abstract final class ZulipIcons { /// The Zulip custom icon "chevron_right". static const IconData chevron_right = IconData(0xf10e, fontFamily: "Zulip Icons", matchTextDirection: true); + /// The Zulip custom icon "chevron_up". + static const IconData chevron_up = IconData(0xf10f, fontFamily: "Zulip Icons"); + /// The Zulip custom icon "circle_x". - static const IconData circle_x = IconData(0xf10f, fontFamily: "Zulip Icons"); + static const IconData circle_x = IconData(0xf110, fontFamily: "Zulip Icons"); /// The Zulip custom icon "clock". - static const IconData clock = IconData(0xf110, fontFamily: "Zulip Icons"); + static const IconData clock = IconData(0xf111, fontFamily: "Zulip Icons"); /// The Zulip custom icon "contacts". - static const IconData contacts = IconData(0xf111, fontFamily: "Zulip Icons"); + static const IconData contacts = IconData(0xf112, fontFamily: "Zulip Icons"); /// The Zulip custom icon "copy". - static const IconData copy = IconData(0xf112, fontFamily: "Zulip Icons"); + static const IconData copy = IconData(0xf113, fontFamily: "Zulip Icons"); /// The Zulip custom icon "edit". - static const IconData edit = IconData(0xf113, fontFamily: "Zulip Icons"); + static const IconData edit = IconData(0xf114, fontFamily: "Zulip Icons"); /// The Zulip custom icon "eye". - static const IconData eye = IconData(0xf114, fontFamily: "Zulip Icons"); + static const IconData eye = IconData(0xf115, fontFamily: "Zulip Icons"); /// The Zulip custom icon "eye_off". - static const IconData eye_off = IconData(0xf115, fontFamily: "Zulip Icons"); + static const IconData eye_off = IconData(0xf116, fontFamily: "Zulip Icons"); /// The Zulip custom icon "follow". - static const IconData follow = IconData(0xf116, fontFamily: "Zulip Icons"); + static const IconData follow = IconData(0xf117, fontFamily: "Zulip Icons"); /// The Zulip custom icon "format_quote". - static const IconData format_quote = IconData(0xf117, fontFamily: "Zulip Icons"); + static const IconData format_quote = IconData(0xf118, fontFamily: "Zulip Icons"); /// The Zulip custom icon "globe". - static const IconData globe = IconData(0xf118, fontFamily: "Zulip Icons"); + static const IconData globe = IconData(0xf119, fontFamily: "Zulip Icons"); /// The Zulip custom icon "group_dm_3". - static const IconData group_dm_3 = IconData(0xf119, fontFamily: "Zulip Icons"); + static const IconData group_dm_3 = IconData(0xf11a, fontFamily: "Zulip Icons"); /// The Zulip custom icon "group_dm_4". - static const IconData group_dm_4 = IconData(0xf11a, fontFamily: "Zulip Icons"); + static const IconData group_dm_4 = IconData(0xf11b, fontFamily: "Zulip Icons"); /// The Zulip custom icon "group_dm_5". - static const IconData group_dm_5 = IconData(0xf11b, fontFamily: "Zulip Icons"); + static const IconData group_dm_5 = IconData(0xf11c, fontFamily: "Zulip Icons"); /// The Zulip custom icon "group_dm_6". - static const IconData group_dm_6 = IconData(0xf11c, fontFamily: "Zulip Icons"); + static const IconData group_dm_6 = IconData(0xf11d, fontFamily: "Zulip Icons"); /// The Zulip custom icon "group_dm_7". - static const IconData group_dm_7 = IconData(0xf11d, fontFamily: "Zulip Icons"); + static const IconData group_dm_7 = IconData(0xf11e, fontFamily: "Zulip Icons"); /// The Zulip custom icon "group_dm_8". - static const IconData group_dm_8 = IconData(0xf11e, fontFamily: "Zulip Icons"); + static const IconData group_dm_8 = IconData(0xf11f, fontFamily: "Zulip Icons"); /// The Zulip custom icon "group_dm_9". - static const IconData group_dm_9 = IconData(0xf11f, fontFamily: "Zulip Icons"); + static const IconData group_dm_9 = IconData(0xf120, fontFamily: "Zulip Icons"); /// The Zulip custom icon "hash_italic". - static const IconData hash_italic = IconData(0xf120, fontFamily: "Zulip Icons"); + static const IconData hash_italic = IconData(0xf121, fontFamily: "Zulip Icons"); /// The Zulip custom icon "hash_sign". - static const IconData hash_sign = IconData(0xf121, fontFamily: "Zulip Icons"); + static const IconData hash_sign = IconData(0xf122, fontFamily: "Zulip Icons"); /// The Zulip custom icon "image". - static const IconData image = IconData(0xf122, fontFamily: "Zulip Icons"); + static const IconData image = IconData(0xf123, fontFamily: "Zulip Icons"); /// The Zulip custom icon "inbox". - static const IconData inbox = IconData(0xf123, fontFamily: "Zulip Icons"); + static const IconData inbox = IconData(0xf124, fontFamily: "Zulip Icons"); /// The Zulip custom icon "info". - static const IconData info = IconData(0xf124, fontFamily: "Zulip Icons"); + static const IconData info = IconData(0xf125, fontFamily: "Zulip Icons"); /// The Zulip custom icon "inherit". - static const IconData inherit = IconData(0xf125, fontFamily: "Zulip Icons"); + static const IconData inherit = IconData(0xf126, fontFamily: "Zulip Icons"); /// The Zulip custom icon "language". - static const IconData language = IconData(0xf126, fontFamily: "Zulip Icons"); + static const IconData language = IconData(0xf127, fontFamily: "Zulip Icons"); /// The Zulip custom icon "link". - static const IconData link = IconData(0xf127, fontFamily: "Zulip Icons"); + static const IconData link = IconData(0xf128, fontFamily: "Zulip Icons"); /// The Zulip custom icon "lock". - static const IconData lock = IconData(0xf128, fontFamily: "Zulip Icons"); + static const IconData lock = IconData(0xf129, fontFamily: "Zulip Icons"); /// The Zulip custom icon "menu". - static const IconData menu = IconData(0xf129, fontFamily: "Zulip Icons"); + static const IconData menu = IconData(0xf12a, fontFamily: "Zulip Icons"); /// The Zulip custom icon "message_checked". - static const IconData message_checked = IconData(0xf12a, fontFamily: "Zulip Icons"); + static const IconData message_checked = IconData(0xf12b, fontFamily: "Zulip Icons"); /// The Zulip custom icon "message_feed". - static const IconData message_feed = IconData(0xf12b, fontFamily: "Zulip Icons", matchTextDirection: true); + static const IconData message_feed = IconData(0xf12c, fontFamily: "Zulip Icons", matchTextDirection: true); /// The Zulip custom icon "more_horizontal". - static const IconData more_horizontal = IconData(0xf12c, fontFamily: "Zulip Icons"); + static const IconData more_horizontal = IconData(0xf12d, fontFamily: "Zulip Icons"); /// The Zulip custom icon "mute". - static const IconData mute = IconData(0xf12d, fontFamily: "Zulip Icons"); + static const IconData mute = IconData(0xf12e, fontFamily: "Zulip Icons"); /// The Zulip custom icon "person". - static const IconData person = IconData(0xf12e, fontFamily: "Zulip Icons"); + static const IconData person = IconData(0xf12f, fontFamily: "Zulip Icons"); /// The Zulip custom icon "pin". - static const IconData pin = IconData(0xf12f, fontFamily: "Zulip Icons"); + static const IconData pin = IconData(0xf130, fontFamily: "Zulip Icons"); /// The Zulip custom icon "pin_remove". - static const IconData pin_remove = IconData(0xf130, fontFamily: "Zulip Icons"); + static const IconData pin_remove = IconData(0xf131, fontFamily: "Zulip Icons"); /// The Zulip custom icon "plus". - static const IconData plus = IconData(0xf131, fontFamily: "Zulip Icons"); + static const IconData plus = IconData(0xf132, fontFamily: "Zulip Icons"); /// The Zulip custom icon "read_receipts". - static const IconData read_receipts = IconData(0xf132, fontFamily: "Zulip Icons"); + static const IconData read_receipts = IconData(0xf133, fontFamily: "Zulip Icons"); /// The Zulip custom icon "remove". - static const IconData remove = IconData(0xf133, fontFamily: "Zulip Icons"); + static const IconData remove = IconData(0xf134, fontFamily: "Zulip Icons"); /// The Zulip custom icon "search". - static const IconData search = IconData(0xf134, fontFamily: "Zulip Icons"); + static const IconData search = IconData(0xf135, fontFamily: "Zulip Icons"); /// The Zulip custom icon "see_who_reacted". - static const IconData see_who_reacted = IconData(0xf135, fontFamily: "Zulip Icons"); + static const IconData see_who_reacted = IconData(0xf136, fontFamily: "Zulip Icons"); /// The Zulip custom icon "send". - static const IconData send = IconData(0xf136, fontFamily: "Zulip Icons", matchTextDirection: true); + static const IconData send = IconData(0xf137, fontFamily: "Zulip Icons", matchTextDirection: true); /// The Zulip custom icon "settings". - static const IconData settings = IconData(0xf137, fontFamily: "Zulip Icons"); + static const IconData settings = IconData(0xf138, fontFamily: "Zulip Icons"); /// The Zulip custom icon "share". - static const IconData share = IconData(0xf138, fontFamily: "Zulip Icons"); + static const IconData share = IconData(0xf139, fontFamily: "Zulip Icons"); /// The Zulip custom icon "share_ios". - static const IconData share_ios = IconData(0xf139, fontFamily: "Zulip Icons"); + static const IconData share_ios = IconData(0xf13a, fontFamily: "Zulip Icons"); /// The Zulip custom icon "smile". - static const IconData smile = IconData(0xf13a, fontFamily: "Zulip Icons"); + static const IconData smile = IconData(0xf13b, fontFamily: "Zulip Icons"); /// The Zulip custom icon "star". - static const IconData star = IconData(0xf13b, fontFamily: "Zulip Icons"); + static const IconData star = IconData(0xf13c, fontFamily: "Zulip Icons"); /// The Zulip custom icon "star_filled". - static const IconData star_filled = IconData(0xf13c, fontFamily: "Zulip Icons"); + static const IconData star_filled = IconData(0xf13d, fontFamily: "Zulip Icons"); /// The Zulip custom icon "three_person". - static const IconData three_person = IconData(0xf13d, fontFamily: "Zulip Icons"); + static const IconData three_person = IconData(0xf13e, fontFamily: "Zulip Icons"); /// The Zulip custom icon "topic". - static const IconData topic = IconData(0xf13e, fontFamily: "Zulip Icons"); + static const IconData topic = IconData(0xf13f, fontFamily: "Zulip Icons"); /// The Zulip custom icon "topics". - static const IconData topics = IconData(0xf13f, fontFamily: "Zulip Icons", matchTextDirection: true); + static const IconData topics = IconData(0xf140, fontFamily: "Zulip Icons", matchTextDirection: true); /// The Zulip custom icon "trash". - static const IconData trash = IconData(0xf140, fontFamily: "Zulip Icons"); + static const IconData trash = IconData(0xf141, fontFamily: "Zulip Icons"); /// The Zulip custom icon "two_person". - static const IconData two_person = IconData(0xf141, fontFamily: "Zulip Icons"); + static const IconData two_person = IconData(0xf142, fontFamily: "Zulip Icons"); /// The Zulip custom icon "unmute". - static const IconData unmute = IconData(0xf142, fontFamily: "Zulip Icons"); + static const IconData unmute = IconData(0xf143, fontFamily: "Zulip Icons"); // END GENERATED ICON DATA } diff --git a/lib/widgets/inbox.dart b/lib/widgets/inbox.dart index 227a4fa44..bedc0ac1c 100644 --- a/lib/widgets/inbox.dart +++ b/lib/widgets/inbox.dart @@ -7,6 +7,7 @@ import '../model/narrow.dart'; import '../model/recent_dm_conversations.dart'; import '../model/unreads.dart'; import 'action_sheet.dart'; +import 'color.dart'; import 'icons.dart'; import 'message_list.dart'; import 'page.dart'; @@ -342,7 +343,7 @@ class InboxDmItem extends StatelessWidget { }, onLongPress: () => showDmActionSheet(context, narrow: narrow), child: ConstrainedBox(constraints: const BoxConstraints(minHeight: 44), - child: Padding(padding: EdgeInsetsDirectional.fromSTEB(25, 0, 16, 0), + child: Padding(padding: EdgeInsetsDirectional.fromSTEB(12, 0, 16, 0), child: Row(crossAxisAlignment: CrossAxisAlignment.center, children: [ DmConversationAvatar(narrow: narrow, backgroundColor: backgroundColor), const SizedBox(width: 6), @@ -432,29 +433,36 @@ class InboxChannelHeaderItem extends StatelessWidget { // 40px min height. onTap: _onCollapseButtonTap, onLongPress: _onLongPress, - child: Padding(padding: EdgeInsetsDirectional.fromSTEB(0, 0, 16, 0), + child: Padding(padding: EdgeInsetsDirectional.fromSTEB(24, 8, 16, 8), child: Row(crossAxisAlignment: CrossAxisAlignment.center, children: [ - Padding(padding: const EdgeInsets.all(10), - child: Icon(size: 20, color: designVariables.sectionCollapseIcon, - collapsed ? ZulipIcons.arrow_right : ZulipIcons.arrow_down)), Icon(size: 18, color: collapsed ? swatch.iconOnPlainBackground : swatch.iconOnBarBackground, iconDataForStream(subscription)), - const SizedBox(width: 5), - Expanded(child: Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: Text( - style: TextStyle( - fontSize: 17, - height: (20 / 17), - // TODO(design) check if this is the right variable - color: designVariables.labelMenuButton, - ).merge(weightVariableTextStyle(context, wght: 600)), - maxLines: 1, - overflow: TextOverflow.ellipsis, - subscription.name))), + const SizedBox(width: 8), + // Pin the chevron to the end of the channel name. + // Let the name grow until the chevron has no room after it, + // truncating overflow with "...". + Expanded(child: Row(mainAxisSize: .min, children: [ + Flexible( + child: Text( + style: TextStyle( + fontSize: 17, + height: (20 / 17), + // TODO(design) check if this is the right variable + color: designVariables.labelMenuButton, + ).merge(weightVariableTextStyle(context, wght: 600)), + maxLines: 1, + overflow: TextOverflow.ellipsis, + subscription.name)), + const SizedBox(width: 6), + Icon(size: 20, + color: designVariables.textMessage.withFadedAlpha(0.5), + // TODO(design) hide icon when uncollapsed? + // Discussion: https://chat.zulip.org/#narrow/channel/530-mobile-design/topic/channel.20folders.20in.20inbox.3A.20design/near/2422785 + collapsed ? ZulipIcons.chevron_down : ZulipIcons.chevron_up), + ])), const SizedBox(width: 12), if (hasMention) const _IconMarker(icon: ZulipIcons.at_sign), CounterBadge( @@ -537,7 +545,7 @@ class InboxTopicItem extends StatelessWidget { topic: topic, someMessageIdInTopic: lastUnreadId), child: ConstrainedBox(constraints: const BoxConstraints(minHeight: 34), - child: Padding(padding: const EdgeInsetsDirectional.fromSTEB(63, 0, 16, 0), + child: Padding(padding: const EdgeInsetsDirectional.fromSTEB(50, 0, 16, 0), child: Row(crossAxisAlignment: CrossAxisAlignment.center, children: [ Expanded(child: Padding( padding: const EdgeInsets.symmetric(vertical: 4), diff --git a/test/widgets/home_test.dart b/test/widgets/home_test.dart index ea9d1a868..2c5ab0a1b 100644 --- a/test/widgets/home_test.dart +++ b/test/widgets/home_test.dart @@ -121,26 +121,26 @@ void main () { Finder findIcon(IconData icon) => find.widgetWithIcon(InboxChannelHeaderItem, icon); - check(findIcon(ZulipIcons.arrow_down)).findsOne(); - check(findIcon(ZulipIcons.arrow_right)).findsNothing(); + check(findIcon(ZulipIcons.chevron_up)).findsOne(); + check(findIcon(ZulipIcons.chevron_down)).findsNothing(); // Collapsing the header updates inbox's internal state. - await tester.tap(findIcon(ZulipIcons.arrow_down)); + await tester.tap(findIcon(ZulipIcons.chevron_up)); await tester.pump(); - check(findIcon(ZulipIcons.arrow_down)).findsNothing(); - check(findIcon(ZulipIcons.arrow_right)).findsOne(); + check(findIcon(ZulipIcons.chevron_up)).findsNothing(); + check(findIcon(ZulipIcons.chevron_down)).findsOne(); // Switch to channels view. await tester.tap(find.byIcon(ZulipIcons.hash_italic)); await tester.pump(); - check(findIcon(ZulipIcons.arrow_down)).findsNothing(); - check(findIcon(ZulipIcons.arrow_right)).findsNothing(); + check(findIcon(ZulipIcons.chevron_up)).findsNothing(); + check(findIcon(ZulipIcons.chevron_down)).findsNothing(); // The header should remain collapsed when we return to the inbox. await tester.tap(find.byIcon(ZulipIcons.inbox)); await tester.pump(); - check(findIcon(ZulipIcons.arrow_down)).findsNothing(); - check(findIcon(ZulipIcons.arrow_right)).findsOne(); + check(findIcon(ZulipIcons.chevron_up)).findsNothing(); + check(findIcon(ZulipIcons.chevron_down)).findsOne(); }); testWidgets('update app bar title and actions when switching between views', (tester) async { diff --git a/test/widgets/inbox_test.dart b/test/widgets/inbox_test.dart index 7ba99c024..5305b8020 100644 --- a/test/widgets/inbox_test.dart +++ b/test/widgets/inbox_test.dart @@ -195,7 +195,7 @@ void main() { check(find.descendant( of: findHeader, matching: find.byIcon( - expectCollapsed ? ZulipIcons.arrow_right : ZulipIcons.arrow_down))).findsOne(); + expectCollapsed ? ZulipIcons.chevron_down : ZulipIcons.chevron_up))).findsOne(); final swatch = colorSwatchFor(element, subscription); @@ -654,8 +654,8 @@ void main() { of: findChannelHeader(subscription.streamId), matching: find.byWidgetPredicate((widget) => widget is Icon - && (widget.icon == ZulipIcons.arrow_down - || widget.icon == ZulipIcons.arrow_right)))); + && (widget.icon == ZulipIcons.chevron_up + || widget.icon == ZulipIcons.chevron_down)))); await tester.pump(); } From d4cba605fd707c896a8dddc7a0be46c7469e815c Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Mon, 30 Mar 2026 22:49:17 -0700 Subject: [PATCH 07/24] inbox: Put unread-count badges 12px from edge of screen, down from 16px Following Figma: https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=16056-6332&m=dev --- lib/widgets/inbox.dart | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/widgets/inbox.dart b/lib/widgets/inbox.dart index bedc0ac1c..7d240b737 100644 --- a/lib/widgets/inbox.dart +++ b/lib/widgets/inbox.dart @@ -343,7 +343,7 @@ class InboxDmItem extends StatelessWidget { }, onLongPress: () => showDmActionSheet(context, narrow: narrow), child: ConstrainedBox(constraints: const BoxConstraints(minHeight: 44), - child: Padding(padding: EdgeInsetsDirectional.fromSTEB(12, 0, 16, 0), + child: Padding(padding: EdgeInsetsDirectional.fromSTEB(12, 0, 12, 0), child: Row(crossAxisAlignment: CrossAxisAlignment.center, children: [ DmConversationAvatar(narrow: narrow, backgroundColor: backgroundColor), const SizedBox(width: 6), @@ -433,7 +433,7 @@ class InboxChannelHeaderItem extends StatelessWidget { // 40px min height. onTap: _onCollapseButtonTap, onLongPress: _onLongPress, - child: Padding(padding: EdgeInsetsDirectional.fromSTEB(24, 8, 16, 8), + child: Padding(padding: EdgeInsetsDirectional.fromSTEB(24, 8, 12, 8), child: Row(crossAxisAlignment: CrossAxisAlignment.center, children: [ Icon(size: 18, color: collapsed @@ -545,7 +545,7 @@ class InboxTopicItem extends StatelessWidget { topic: topic, someMessageIdInTopic: lastUnreadId), child: ConstrainedBox(constraints: const BoxConstraints(minHeight: 34), - child: Padding(padding: const EdgeInsetsDirectional.fromSTEB(50, 0, 16, 0), + child: Padding(padding: const EdgeInsetsDirectional.fromSTEB(50, 0, 12, 0), child: Row(crossAxisAlignment: CrossAxisAlignment.center, children: [ Expanded(child: Padding( padding: const EdgeInsets.symmetric(vertical: 4), From a2aa7162f678ba6b39a7c856db9ad51c46d82569 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Mon, 30 Mar 2026 23:10:33 -0700 Subject: [PATCH 08/24] inbox: Increase vertical padding in folder headers from 8px to 10px The most recent Figma frame has 10px: https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=16056-6580&m=dev 8px comes from older ones: https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=13188-52022&m=dev --- lib/widgets/inbox.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/widgets/inbox.dart b/lib/widgets/inbox.dart index 7d240b737..3d7f14e75 100644 --- a/lib/widgets/inbox.dart +++ b/lib/widgets/inbox.dart @@ -284,7 +284,7 @@ class InboxFolderHeaderItem extends StatelessWidget { Widget result = ColoredBox( color: designVariables.background, // TODO(design) check if this is the right variable child: Padding( - padding: EdgeInsetsDirectional.fromSTEB(14, 8, 12, 8), + padding: EdgeInsetsDirectional.fromSTEB(14, 10, 12, 10), child: Row(crossAxisAlignment: CrossAxisAlignment.center, spacing: 8, children: [ Expanded( child: Text( From df7bd5b2e97564802b6689f28aa4f1a4a3409c69 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Mon, 30 Mar 2026 23:10:10 -0700 Subject: [PATCH 09/24] inbox: Adjust spacing after row text to match Figma (Except in one case as noted.) Figma: https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=16056-6562&m=dev --- lib/widgets/inbox.dart | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/lib/widgets/inbox.dart b/lib/widgets/inbox.dart index 3d7f14e75..d8d8b0ec1 100644 --- a/lib/widgets/inbox.dart +++ b/lib/widgets/inbox.dart @@ -359,7 +359,8 @@ class InboxDmItem extends StatelessWidget { maxLines: 2, overflow: TextOverflow.ellipsis, title))), - const SizedBox(width: 12), + // 6 in Figma, but 8 is consistent with channel and topic rows + const SizedBox(width: 8), if (hasMention) const _IconMarker(icon: ZulipIcons.at_sign), CounterBadge( // TODO(design) use CounterKind.quantity, following Figma @@ -463,7 +464,7 @@ class InboxChannelHeaderItem extends StatelessWidget { // Discussion: https://chat.zulip.org/#narrow/channel/530-mobile-design/topic/channel.20folders.20in.20inbox.3A.20design/near/2422785 collapsed ? ZulipIcons.chevron_down : ZulipIcons.chevron_up), ])), - const SizedBox(width: 12), + const SizedBox(width: 8), if (hasMention) const _IconMarker(icon: ZulipIcons.at_sign), CounterBadge( // TODO(design) use CounterKind.quantity, following Figma @@ -560,7 +561,7 @@ class InboxTopicItem extends StatelessWidget { maxLines: 2, overflow: TextOverflow.ellipsis, topic.displayName ?? store.realmEmptyTopicDisplayName))), - const SizedBox(width: 12), + const SizedBox(width: 8), if (hasMention) const _IconMarker(icon: ZulipIcons.at_sign), if (visibilityIcon != null) _IconMarker(icon: visibilityIcon), CounterBadge( From 44df824aad5f276d852247579ebd14913ccb99b2 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Mon, 30 Mar 2026 23:07:53 -0700 Subject: [PATCH 10/24] inbox: Adjust text styles to match Figma --- lib/widgets/inbox.dart | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/lib/widgets/inbox.dart b/lib/widgets/inbox.dart index d8d8b0ec1..3a18aca49 100644 --- a/lib/widgets/inbox.dart +++ b/lib/widgets/inbox.dart @@ -292,10 +292,10 @@ class InboxFolderHeaderItem extends StatelessWidget { overflow: .ellipsis, style: TextStyle( color: designVariables.folderText, - fontSize: 16, - height: 20 / 16, - letterSpacing: proportionalLetterSpacing(context, 0.02, baseFontSize: 16), - ).merge(weightVariableTextStyle(context, wght: 600)), + fontSize: 17, + height: 20 / 17, + letterSpacing: proportionalLetterSpacing(context, 0.02, baseFontSize: 17), + ).merge(weightVariableTextStyle(context, wght: 700)), label.toUpperCase())), ]))); @@ -352,9 +352,8 @@ class InboxDmItem extends StatelessWidget { child: Text( style: TextStyle( fontSize: 17, - height: (20 / 17), - // TODO(design) check if this is the right variable - color: designVariables.labelMenuButton, + height: (19 / 17), + color: designVariables.textMessage, ), maxLines: 2, overflow: TextOverflow.ellipsis, @@ -451,8 +450,7 @@ class InboxChannelHeaderItem extends StatelessWidget { style: TextStyle( fontSize: 17, height: (20 / 17), - // TODO(design) check if this is the right variable - color: designVariables.labelMenuButton, + color: designVariables.textMessage, ).merge(weightVariableTextStyle(context, wght: 600)), maxLines: 1, overflow: TextOverflow.ellipsis, @@ -555,10 +553,9 @@ class InboxTopicItem extends StatelessWidget { fontSize: 17, height: (20 / 17), fontStyle: topic.displayName == null ? FontStyle.italic : null, - // TODO(design) check if this is the right variable - color: designVariables.labelMenuButton, + color: designVariables.textMessage, ), - maxLines: 2, + maxLines: 3, overflow: TextOverflow.ellipsis, topic.displayName ?? store.realmEmptyTopicDisplayName))), const SizedBox(width: 8), From 20325e751d7ea8e762a3e45d75a35f22461bcae1 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Tue, 31 Mar 2026 01:10:40 -0700 Subject: [PATCH 11/24] inbox: Add gradient effect for channel rows From Figma: https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=16056-6562&m=dev though I'm just using my judgment to choose the starting color for the gradient; I don't see a formula to derive this from the channel's base color or another color in its swatch; discussion: https://chat.zulip.org/#narrow/channel/530-mobile-design/topic/channel.20folders.20in.20inbox.3A.20design/near/2422786 --- lib/widgets/inbox.dart | 148 +++++++++++++++++++++-------------- test/widgets/inbox_test.dart | 26 +----- 2 files changed, 93 insertions(+), 81 deletions(-) diff --git a/lib/widgets/inbox.dart b/lib/widgets/inbox.dart index 3a18aca49..80fec04c4 100644 --- a/lib/widgets/inbox.dart +++ b/lib/widgets/inbox.dart @@ -7,6 +7,7 @@ import '../model/narrow.dart'; import '../model/recent_dm_conversations.dart'; import '../model/unreads.dart'; import 'action_sheet.dart'; +import 'channel_colors.dart'; import 'color.dart'; import 'icons.dart'; import 'message_list.dart'; @@ -377,6 +378,7 @@ class InboxDmItem extends StatelessWidget { class InboxChannelHeaderItem extends StatelessWidget { const InboxChannelHeaderItem({ super.key, + this.isSticky = false, required this.subscription, required this.collapsed, required this.pageState, @@ -385,6 +387,9 @@ class InboxChannelHeaderItem extends StatelessWidget { required this.sectionContext, }); + /// Whether this is the widget that gets passed to [StickyHeaderItem.header]. + final bool isSticky; + final Subscription subscription; final bool collapsed; final InboxPageState pageState; @@ -414,6 +419,22 @@ class InboxChannelHeaderItem extends StatelessWidget { showChannelActionSheet(sectionContext, channelId: subscription.streamId); } + BoxDecoration _solidBackground(ChannelColorSwatch swatch) => + BoxDecoration(color: swatch.barBackground); + + BoxDecoration _gradientBackground(ChannelColorSwatch swatch) => BoxDecoration( + gradient: LinearGradient( + begin: .topCenter, + end: .bottomCenter, + colors: [ + // TODO(design) is this the right color? + // https://chat.zulip.org/#narrow/channel/530-mobile-design/topic/channel.20folders.20in.20inbox.3A.20design/near/2422786 + swatch.barBackground, + swatch.barBackground.withValues(alpha: 0), + ], + ), + ); + @override Widget build(BuildContext context) { final designVariables = DesignVariables.of(context); @@ -421,55 +442,57 @@ class InboxChannelHeaderItem extends StatelessWidget { final swatch = colorSwatchFor(context, subscription); Widget result = Material( - color: collapsed - ? designVariables.background // TODO(design) check if this is the right variable - : swatch.barBackground, - child: InkWell( - // TODO use onRowTap to handle taps that are not on the collapse button. - // Probably we should give the collapse button a 44px or 48px square - // touch target: - // - // But that's in tension with the Figma, which gives these header rows - // 40px min height. - onTap: _onCollapseButtonTap, - onLongPress: _onLongPress, - child: Padding(padding: EdgeInsetsDirectional.fromSTEB(24, 8, 12, 8), - child: Row(crossAxisAlignment: CrossAxisAlignment.center, children: [ - Icon(size: 18, - color: collapsed - ? swatch.iconOnPlainBackground - : swatch.iconOnBarBackground, - iconDataForStream(subscription)), - const SizedBox(width: 8), - // Pin the chevron to the end of the channel name. - // Let the name grow until the chevron has no room after it, - // truncating overflow with "...". - Expanded(child: Row(mainAxisSize: .min, children: [ - Flexible( - child: Text( - style: TextStyle( - fontSize: 17, - height: (20 / 17), - color: designVariables.textMessage, - ).merge(weightVariableTextStyle(context, wght: 600)), - maxLines: 1, - overflow: TextOverflow.ellipsis, - subscription.name)), - const SizedBox(width: 6), - Icon(size: 20, - color: designVariables.textMessage.withFadedAlpha(0.5), - // TODO(design) hide icon when uncollapsed? - // Discussion: https://chat.zulip.org/#narrow/channel/530-mobile-design/topic/channel.20folders.20in.20inbox.3A.20design/near/2422785 - collapsed ? ZulipIcons.chevron_down : ZulipIcons.chevron_up), - ])), - const SizedBox(width: 8), - if (hasMention) const _IconMarker(icon: ZulipIcons.at_sign), - CounterBadge( - // TODO(design) use CounterKind.quantity, following Figma - kind: CounterBadgeKind.unread, - channelIdForBackground: subscription.streamId, - count: count), - ])))); + color: designVariables.background, // TODO(design) check if this is the right variable + child: DecoratedBox( + decoration: (collapsed || isSticky) + // TODO(design) settle whether to use a solid background: + // https://chat.zulip.org/#narrow/channel/530-mobile-design/topic/channel.20folders.20in.20inbox.3A.20design/near/2423220 + ? _solidBackground(swatch) + : _gradientBackground(swatch), + child: InkWell( + // TODO use onRowTap to handle taps that are not on the collapse button. + // Probably we should give the collapse button a 44px or 48px square + // touch target: + // + // But that's in tension with the Figma, which gives these header rows + // 40px min height. + onTap: _onCollapseButtonTap, + onLongPress: _onLongPress, + child: Padding(padding: EdgeInsetsDirectional.fromSTEB(24, 8, 12, 8), + child: Row(crossAxisAlignment: CrossAxisAlignment.center, children: [ + Icon(size: 18, + color: swatch.iconOnBarBackground, + iconDataForStream(subscription)), + const SizedBox(width: 8), + // Pin the chevron to the end of the channel name. + // Let the name grow until the chevron has no room after it, + // truncating overflow with "...". + Expanded(child: Row(mainAxisSize: .min, children: [ + Flexible( + child: Text( + style: TextStyle( + fontSize: 17, + height: (20 / 17), + color: designVariables.textMessage, + ).merge(weightVariableTextStyle(context, wght: 600)), + maxLines: 1, + overflow: TextOverflow.ellipsis, + subscription.name)), + const SizedBox(width: 6), + Icon(size: 20, + color: designVariables.textMessage.withFadedAlpha(0.5), + // TODO(design) hide icon when uncollapsed? + // Discussion: https://chat.zulip.org/#narrow/channel/530-mobile-design/topic/channel.20folders.20in.20inbox.3A.20design/near/2422785 + collapsed ? ZulipIcons.chevron_down : ZulipIcons.chevron_up), + ])), + const SizedBox(width: 8), + if (hasMention) const _IconMarker(icon: ZulipIcons.at_sign), + CounterBadge( + // TODO(design) use CounterKind.quantity, following Figma + kind: CounterBadgeKind.unread, + channelIdForBackground: subscription.streamId, + count: count), + ]))))); return Semantics(container: true, child: result); @@ -490,18 +513,25 @@ class _StreamSection extends StatelessWidget { @override Widget build(BuildContext context) { final subscription = PerAccountStoreWidget.of(context).subscriptions[data.streamId]!; - final header = InboxChannelHeaderItem( - subscription: subscription, - count: data.count, - hasMention: data.hasMention, - collapsed: collapsed, - pageState: pageState, - sectionContext: context, - ); return StickyHeaderItem( - header: header, + header: InboxChannelHeaderItem( + isSticky: true, + subscription: subscription, + count: data.count, + hasMention: data.hasMention, + collapsed: collapsed, + pageState: pageState, + sectionContext: context, + ), child: Column(children: [ - header, + InboxChannelHeaderItem( + subscription: subscription, + count: data.count, + hasMention: data.hasMention, + collapsed: collapsed, + pageState: pageState, + sectionContext: context, + ), if (!collapsed) ...data.items.map((item) { return InboxTopicItem(streamId: data.streamId, data: item); }), diff --git a/test/widgets/inbox_test.dart b/test/widgets/inbox_test.dart index 5305b8020..faa90e75f 100644 --- a/test/widgets/inbox_test.dart +++ b/test/widgets/inbox_test.dart @@ -2,7 +2,6 @@ import 'package:checks/checks.dart'; import 'package:flutter/material.dart'; import 'package:flutter_checks/flutter_checks.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:legacy_checks/legacy_checks.dart'; import 'package:zulip/api/model/events.dart'; import 'package:zulip/api/model/model.dart'; import 'package:zulip/model/narrow.dart'; @@ -176,7 +175,6 @@ void main() { Finder? findSectionContent, }) { final findHeader = findChannelHeader(subscription.streamId); - final element = tester.element(findHeader); if (expectAtSignIcon != null) { check(find.descendant(of: findHeader, matching: find.byIcon(ZulipIcons.at_sign))) @@ -188,8 +186,8 @@ void main() { Subscription(inviteOnly: true) => ZulipIcons.lock, Subscription() => ZulipIcons.hash_sign, }; - final channelIcon = tester.widget( - find.descendant(of: findHeader, matching: find.byIcon(expectedChannelIcon))); + check(find.descendant(of: findHeader, matching: find.byIcon(expectedChannelIcon))) + .findsOne(); if (expectCollapsed != null) { check(find.descendant( @@ -197,24 +195,8 @@ void main() { matching: find.byIcon( expectCollapsed ? ZulipIcons.chevron_down : ZulipIcons.chevron_up))).findsOne(); - final swatch = colorSwatchFor(element, subscription); - - check(channelIcon).color.isNotNull() - .isSameColorAs(expectCollapsed - ? swatch.iconOnPlainBackground - : swatch.iconOnBarBackground); - - final renderObject = tester.renderObject(findHeader); - final paintBounds = renderObject.paintBounds; - - // `paints` isn't a [Matcher] so we wrap it with `equals`; - // awkward but it works - check(renderObject).legacyMatcher(equals(paints..rrect( - rrect: RRect.fromRectAndRadius(paintBounds, Radius.zero), - style: .fill, - color: expectCollapsed - ? Colors.white - : swatch.barBackground))); + // TODO could test bar background (not finding a way just now to + // expect a gradient to be painted) if (findSectionContent != null) { check(findSectionContent).findsExactly(expectCollapsed ? 0 : 1); From 7bdc06fecb32c55a30dcd465fd5be1bffacd041e Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Tue, 31 Mar 2026 01:49:46 -0700 Subject: [PATCH 12/24] inbox: Add top border to channel-folder header From Figma: https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=16056-6332&m=dev --- lib/widgets/inbox.dart | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/lib/widgets/inbox.dart b/lib/widgets/inbox.dart index 80fec04c4..4ff364d11 100644 --- a/lib/widgets/inbox.dart +++ b/lib/widgets/inbox.dart @@ -282,8 +282,11 @@ class InboxFolderHeaderItem extends StatelessWidget { @override Widget build(BuildContext context) { final designVariables = DesignVariables.of(context); - Widget result = ColoredBox( - color: designVariables.background, // TODO(design) check if this is the right variable + Widget result = DecoratedBox( + decoration: BoxDecoration( + color: designVariables.background, // TODO(design) check if this is the right variable + border: Border(top: BorderSide(color: designVariables.borderBar)), + ), child: Padding( padding: EdgeInsetsDirectional.fromSTEB(14, 10, 12, 10), child: Row(crossAxisAlignment: CrossAxisAlignment.center, spacing: 8, children: [ From b078b5cc8ecaaae9488dd50bb0f8d737cf8ca99a Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Tue, 31 Mar 2026 01:36:16 -0700 Subject: [PATCH 13/24] inbox: Remove "splash" touch feedback The gray color of the splash effect clashes with the channel-color rows. We could fix that by recoloring the splash, but the animation doesn't appeal to me for other reasons (and this is from my experience as an iOS user; I don't use the Android app for daily use): it looks like a simple filled circle with a hard edge, expanding rather slowly from the point of contact. I think I remember seeing a different, more refined animation on Android, which looked better -- meaning Material conditions this by platform? -- but anyway, here's a reasonable alternative that works fine on both platforms. Discussion: https://github.com/zulip/zulip-flutter/pull/2262#discussion_r3061477113 Fixes-partly: #417 --- lib/widgets/inbox.dart | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/lib/widgets/inbox.dart b/lib/widgets/inbox.dart index 4ff364d11..90ccd6079 100644 --- a/lib/widgets/inbox.dart +++ b/lib/widgets/inbox.dart @@ -341,6 +341,9 @@ class InboxDmItem extends StatelessWidget { Widget result = Material( color: backgroundColor, child: InkWell( + splashFactory: NoSplash.splashFactory, + // TODO(design) this is ad hoc + highlightColor: designVariables.foreground.withFadedAlpha(0.05), onTap: () { Navigator.push(context, MessageListPage.buildRoute(context: context, narrow: narrow)); @@ -453,6 +456,9 @@ class InboxChannelHeaderItem extends StatelessWidget { ? _solidBackground(swatch) : _gradientBackground(swatch), child: InkWell( + splashFactory: NoSplash.splashFactory, + // TODO(design) this is ad hoc + highlightColor: swatch.barBackground.withFadedAlpha(0.5), // TODO use onRowTap to handle taps that are not on the collapse button. // Probably we should give the collapse button a 44px or 48px square // touch target: @@ -559,6 +565,8 @@ class InboxTopicItem extends StatelessWidget { :topic, :count, :hasMention, :lastUnreadId) = data; final store = PerAccountStoreWidget.of(context); + final subscription = store.subscriptions[streamId]; + final swatch = colorSwatchFor(context, subscription); final designVariables = DesignVariables.of(context); final visibilityIcon = iconDataForTopicVisibilityPolicy( @@ -567,6 +575,9 @@ class InboxTopicItem extends StatelessWidget { Widget result = Material( color: designVariables.background, // TODO(design) check if this is the right variable child: InkWell( + splashFactory: NoSplash.splashFactory, + // TODO(design) this is ad hoc + highlightColor: swatch.barBackground.withFadedAlpha(0.25), onTap: () { final narrow = TopicNarrow(streamId, topic); Navigator.push(context, From 2576e54363e713a27602c92efe35d07db82fae16 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Mon, 30 Mar 2026 18:01:27 -0700 Subject: [PATCH 14/24] text [nfc]: Pull out InlineIcon from iconWidgetSpan For the checkmark icon in the topic-list (#1528) and inbox pages, coming up, we'll want a baseline-adjusted icon -- the checkmark -- as a child of a baseline-aligned Row, rather than as a sub-span in a span of text. This commit factors out a widget for that, InlineIcon. An static method asWidgetSpan supports the existing callers that do want a WidgetSpan. --- lib/widgets/text.dart | 124 +++++++++++++++++++++++++++++------------- 1 file changed, 85 insertions(+), 39 deletions(-) diff --git a/lib/widgets/text.dart b/lib/widgets/text.dart index 10a648502..b96e41c07 100644 --- a/lib/widgets/text.dart +++ b/lib/widgets/text.dart @@ -597,50 +597,96 @@ class InlineIconGeometryData { ); } -/// An icon, sized and aligned for use in a span of text. -WidgetSpan iconWidgetSpan({ - required IconData icon, - required double fontSize, - required TextBaseline baselineType, - required Color? color, - bool padBefore = false, - bool padAfter = false, -}) { - final InlineIconGeometryData( - :sizeFactor, - :alphabeticBaselineFactor, - :paddingFactor, - ) = InlineIconGeometryData.forIcon(icon); +/// An [Icon] that is sized, aligned, and (optionally) padded for use in text. +/// +/// Use [InlineIcon.asWidgetSpan] for a [WidgetSpan] wrapping one of these. +/// +/// [icon] must be square and have a corresponding entry in +/// [InlineIconGeometryData]. +class InlineIcon extends StatelessWidget { + const InlineIcon({ + super.key, + required this.icon, + required this.fontSize, + this.baselineType, + required this.color, + this.padBefore = false, + this.padAfter = false, + }); - final size = sizeFactor * fontSize; + final IconData icon; + final double fontSize; - final effectiveBaselineOffset = switch (baselineType) { - TextBaseline.alphabetic => alphabeticBaselineFactor * size, - TextBaseline.ideographic => 0.0, - }; + /// The [TextBaseline] to apply (alphabetic or ideographic). + /// + /// If null, [localizedTextBaseline] is used. + final TextBaseline? baselineType; - Widget child = Icon(size: size, color: color, icon); + final Color? color; + final bool padBefore; + final bool padAfter; - if (effectiveBaselineOffset != 0) { - child = Transform.translate( - offset: Offset(0, effectiveBaselineOffset), - child: child); + /// Creates an [InlineIcon] wrapped in a [WidgetSpan]. + /// + /// The [WidgetSpan] has [PlaceholderAlignment.baseline]. + static InlineSpan asWidgetSpan({ + required IconData icon, + required double fontSize, + required TextBaseline baselineType, + required Color? color, + bool padBefore = false, + bool padAfter = false, + }) { + return WidgetSpan( + alignment: PlaceholderAlignment.baseline, + baseline: baselineType, + child: InlineIcon( + icon: icon, + fontSize: fontSize, + baselineType: baselineType, + color: color, + padBefore: padBefore, + padAfter: padAfter, + )); } - if (padBefore || padAfter) { - final padding = paddingFactor * size; - child = Padding( - padding: EdgeInsetsDirectional.only( - start: padBefore ? padding : 0, - end: padAfter ? padding : 0, - ), - child: child); - } + @override + Widget build(BuildContext context) { + final baselineType = this.baselineType ?? localizedTextBaseline(context); + + final InlineIconGeometryData( + :sizeFactor, + :alphabeticBaselineFactor, + :paddingFactor, + ) = InlineIconGeometryData.forIcon(icon); + + final size = sizeFactor * fontSize; + + final effectiveBaselineOffset = switch (baselineType) { + TextBaseline.alphabetic => alphabeticBaselineFactor * size, + TextBaseline.ideographic => 0.0, + }; + + Widget result = Icon(size: size, color: color, icon); - return WidgetSpan( - alignment: PlaceholderAlignment.baseline, - baseline: baselineType, - child: child); + if (effectiveBaselineOffset != 0) { + result = Transform.translate( + offset: Offset(0, effectiveBaselineOffset), + child: result); + } + + if (padBefore || padAfter) { + final padding = paddingFactor * size; + result = Padding( + padding: EdgeInsetsDirectional.only( + start: padBefore ? padding : 0, + end: padAfter ? padding : 0, + ), + child: result); + } + + return result; + } } /// An [InlineSpan] with a channel privacy icon, channel name, @@ -666,7 +712,7 @@ InlineSpan channelTopicLabelSpan({ return TextSpan(children: [ if (channelIcon != null) - iconWidgetSpan( + InlineIcon.asWidgetSpan( icon: channelIcon, fontSize: fontSize, baselineType: baselineType, @@ -679,7 +725,7 @@ InlineSpan channelTopicLabelSpan({ style: TextStyle(fontStyle: FontStyle.italic), text: zulipLocalizations.unknownChannelName), if (topic != null) ...[ - iconWidgetSpan( + InlineIcon.asWidgetSpan( icon: ZulipIcons.chevron_right, fontSize: fontSize, baselineType: baselineType, From 26bb0aa869c8faf5f74c5854b33b0b871e503c5d Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Thu, 16 Apr 2026 12:12:54 -0700 Subject: [PATCH 15/24] text [nfc]: Pin down alphabeticBaselineFactor a bit more; refactor signs --- lib/widgets/text.dart | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/lib/widgets/text.dart b/lib/widgets/text.dart index b96e41c07..f71f5c50c 100644 --- a/lib/widgets/text.dart +++ b/lib/widgets/text.dart @@ -544,7 +544,8 @@ class InlineIconGeometryData { /// as a fraction of the surrounding text's font size. final double sizeFactor; - /// Where to assign the icon's baseline, as a fraction of the icon's size, + /// How far above the icon's bottom edge to assign the icon's baseline, + /// as a fraction of the icon's size, /// when the span is rendered with [TextBaseline.alphabetic]. /// /// This is ignored when the span is rendered with [TextBaseline.ideographic]; @@ -663,7 +664,9 @@ class InlineIcon extends StatelessWidget { final size = sizeFactor * fontSize; final effectiveBaselineOffset = switch (baselineType) { - TextBaseline.alphabetic => alphabeticBaselineFactor * size, + // I.e., consider the baseline to be farther up than the icon's bottom edge, + // at a smaller y-value. + TextBaseline.alphabetic => -alphabeticBaselineFactor * size, TextBaseline.ideographic => 0.0, }; @@ -671,7 +674,9 @@ class InlineIcon extends StatelessWidget { if (effectiveBaselineOffset != 0) { result = Transform.translate( - offset: Offset(0, effectiveBaselineOffset), + // Scoot the icon downward (to greater y-values) by the magnitude of + // effectiveBaselineOffset. + offset: Offset(0, -effectiveBaselineOffset), child: result); } From 100235cbe3b346aa4ad8e07fa49f2736ea606469 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Wed, 15 Apr 2026 20:18:16 -0700 Subject: [PATCH 16/24] text: Replace Translate.transform hack with new AdjustBaseline widget I realized, after Greg pointed out an alignment issue I didn't expect, that the Translate.transform way of adjusting these icons is kind of a hack, and that it's better to make a widget with a custom render object, as Greg originally proposed in #1528 and as Zixuan made a draft of: https://chat.zulip.org/#narrow/channel/243-mobile-team/topic/topic.20list.20item.20alignment/near/2173433 The problem is in how InlineIcon presents itself to a parent Row for shrinkwrapping: it presents the square where the icon would have been without the downward shift, rather than the square where the icon actually is, leading to the icon appearing lower than it should: https://github.com/zulip/zulip-flutter/pull/2262#discussion_r3076930173 So I asked Claude to iterate on Zixuan's draft, linked above, and it helped me produce this widget (named AdjustBaseline as Greg proposed). Substantive differences are: - It applies dy even when the child widget has no baseline, using the child's bottom edge instead. The Icon widget does have a baseline (it's backed by a RichText), but some widgets don't; notably Image, which would come up if we used this for image emoji. - It also overrides RenderBox.computeDryBaseline, not just computeDistanceToActualBaseline. The override gives a result consistent with computeDistanceToActualBaseline, for use during a "speculative layout phase" when the parent is a widget like IntrinsicHeight. - The render object's _dy setter skips the `markNeedsLayout()` if the new number equals the old number. Co-authored-by: Zixuan James Li --- lib/widgets/text.dart | 87 +++++++++++++++++++++++-- test/widgets/text_test.dart | 124 ++++++++++++++++++++++++++++++++++++ 2 files changed, 207 insertions(+), 4 deletions(-) diff --git a/lib/widgets/text.dart b/lib/widgets/text.dart index f71f5c50c..5d48ef457 100644 --- a/lib/widgets/text.dart +++ b/lib/widgets/text.dart @@ -3,6 +3,7 @@ import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; import '../api/model/model.dart'; import '../generated/l10n/zulip_localizations.dart'; @@ -538,6 +539,86 @@ class _TextWithLinkState extends State { } } +/// A widget that reports its baseline as offset by [dy] +/// from its child's baseline. +/// +/// At layout time, passes its constraints through to its child +/// and takes the same size as its child. +/// A positive [dy] shifts the reported baseline downward +/// (toward larger y values). +/// +/// If the child has no baseline, the child's bottom edge is used, +/// matching the convention used by [Baseline] and by [WidgetSpan] +/// with [PlaceholderAlignment.baseline]. +// TODO(upstream) contribute this upstream? +// There's no upstream widget for this as of 2026-04-16. +class AdjustBaseline extends SingleChildRenderObjectWidget { + const AdjustBaseline({ + super.key, + required this.dy, + super.child, + }); + + final double dy; + + @override + RenderAdjustBaseline createRenderObject(BuildContext context) { + return RenderAdjustBaseline(dy: dy); + } + + @override + void updateRenderObject( + BuildContext context, + RenderAdjustBaseline renderObject, + ) { + renderObject.dy = dy; + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DoubleProperty('dy', dy)); + } +} + +class RenderAdjustBaseline extends RenderProxyBox { + RenderAdjustBaseline({required this._dy, RenderBox? child}) + : super(child); + + double get dy => _dy; + double _dy; + set dy(double value) { + if (_dy == value) return; + _dy = value; + markNeedsLayout(); + } + + @override + double? computeDistanceToActualBaseline(TextBaseline baseline) { + final childBaseline = child?.getDistanceToActualBaseline(baseline); + if (childBaseline != null) return childBaseline + dy; + // Fall back to the bottom edge when the child reports no baseline, + // matching [RenderBox.getDistanceToBaseline]'s convention. + return size.height + dy; + } + + @override + @protected + double? computeDryBaseline(BoxConstraints constraints, TextBaseline baseline) { + final childBaseline = child?.getDryBaseline(constraints, baseline); + if (childBaseline != null) return childBaseline + dy; + // Like in [computeDistanceToActualBaseline], fall back to the bottom edge, + // so that dry and actual baselines agree for baseline-less children. + return getDryLayout(constraints).height + dy; + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DoubleProperty('dy', dy)); + } +} + /// Data to size and position a square icon in a span of text. class InlineIconGeometryData { /// What size the icon should be, @@ -673,10 +754,8 @@ class InlineIcon extends StatelessWidget { Widget result = Icon(size: size, color: color, icon); if (effectiveBaselineOffset != 0) { - result = Transform.translate( - // Scoot the icon downward (to greater y-values) by the magnitude of - // effectiveBaselineOffset. - offset: Offset(0, -effectiveBaselineOffset), + result = AdjustBaseline( + dy: effectiveBaselineOffset, child: result); } diff --git a/test/widgets/text_test.dart b/test/widgets/text_test.dart index e3f849dfc..d76eb4802 100644 --- a/test/widgets/text_test.dart +++ b/test/widgets/text_test.dart @@ -468,4 +468,128 @@ void main() { check(calls).equals(0); }); }); + + group('AdjustBaseline', () { + const textKey = ValueKey('adjust-baseline-text'); + // A small font size, well under the Baseline target below, + // so there's plenty of room for positive and negative dy values. + const textWidget = Text('hi', key: textKey, style: TextStyle(fontSize: 20)); + + // Wrap [textWidget] in a Baseline widget at a fixed baseline target, + // optionally interposing an [AdjustBaseline] with the given [dy]. + // Return the Text's screen y-coordinate, which reflects where + // the parent Baseline widget ends up positioning it based on + // the reported baseline of its child. + Future pumpAndMeasure(WidgetTester tester, { + required double? dy, + }) async { + await tester.pumpWidget(Directionality( + textDirection: TextDirection.ltr, + child: Center(child: SizedBox( + width: 200, height: 200, + child: Baseline( + baseline: 100, baselineType: .alphabetic, + child: dy == null + ? textWidget + : AdjustBaseline(dy: dy, child: textWidget)))))); + return tester.getTopLeft(find.byKey(textKey)).dy; + } + + testWidgets('takes same size as child', (tester) async { + await tester.pumpWidget(Center( + child: AdjustBaseline(dy: 5, + child: const SizedBox(width: 100, height: 80)))); + final renderObject = tester.renderObject( + find.byType(AdjustBaseline)); + check(renderObject.size).equals(const Size(100, 80)); + }); + + for (final dy in [7.0, -3.0, 0.0]) { + testWidgets('shifts actual baseline by dy = $dy', (tester) async { + final yReference = await pumpAndMeasure(tester, dy: null); + final yShifted = await pumpAndMeasure(tester, dy: dy); + // A positive dy moves the reported baseline downward, + // so the Baseline widget positions the child higher (smaller y) + // to land the baseline at its target. + check(yShifted - yReference).equals(-dy); + }); + } + + testWidgets('shifts dry baseline by dy', (tester) async { + await tester.pumpWidget(Directionality( + textDirection: TextDirection.ltr, + child: Center(child: AdjustBaseline(dy: 9, child: textWidget)))); + final renderObject = tester.renderObject( + find.byType(AdjustBaseline)); + final constraints = renderObject.constraints; + final childDry = renderObject.child! + .getDryBaseline(constraints, .alphabetic); + check(renderObject.getDryBaseline(constraints, .alphabetic)) + .equals(childDry! + 9); + }); + + group('no-baseline child', () { + // A [SizedBox] has no baseline of its own; its bottom edge + // should be used instead. + const sizedBoxKey = ValueKey('adjust-baseline-sized'); + const sizedBoxChild = SizedBox(key: sizedBoxKey, width: 20, height: 30); + + testWidgets('shifts actual baseline using child bottom', (tester) async { + // Without AdjustBaseline, Baseline widget falls back to + // SizedBox.size.height (= 30) as the baseline, positioning the + // SizedBox at top = 100 - 30 = 70. With AdjustBaseline(dy: 3), + // the reported baseline is 30 + 3 = 33, positioning the SizedBox + // 3 units higher. + Future measure({required bool wrapped}) async { + await tester.pumpWidget(Directionality( + textDirection: TextDirection.ltr, + child: Center(child: SizedBox( + width: 200, height: 200, + child: Baseline( + baseline: 100, baselineType: .alphabetic, + child: wrapped + ? const AdjustBaseline(dy: 3, child: sizedBoxChild) + : sizedBoxChild))))); + return tester.getTopLeft(find.byKey(sizedBoxKey)).dy; + } + final yReference = await measure(wrapped: false); + final yShifted = await measure(wrapped: true); + check(yShifted - yReference).equals(-3); + }); + + testWidgets('shifts dry baseline using child bottom', (tester) async { + await tester.pumpWidget(Directionality( + textDirection: TextDirection.ltr, + child: Center(child: AdjustBaseline(dy: 4, child: sizedBoxChild)))); + final renderObject = tester.renderObject( + find.byType(AdjustBaseline)); + final constraints = renderObject.constraints; + check(renderObject.child! + .getDryBaseline(constraints, .alphabetic)).isNull(); + check(renderObject.getDryBaseline(constraints, .alphabetic)) + .equals(renderObject.child!.getDryLayout(constraints).height + 4); + }); + }); + + testWidgets('updates reported baseline when dy changes', (tester) async { + final y5 = await pumpAndMeasure(tester, dy: 5); + final y12 = await pumpAndMeasure(tester, dy: 12); + check(y12 - y5).equals(-7); + }); + + testWidgets('no relayout when dy setter is called with same value', (tester) async { + await tester.pumpWidget(Directionality( + textDirection: TextDirection.ltr, + child: Center(child: AdjustBaseline(dy: 5, child: textWidget)))); + final renderObject = tester.renderObject( + find.byType(AdjustBaseline)); + check(renderObject.debugNeedsLayout).isFalse(); + renderObject.dy = 5; + check(renderObject.debugNeedsLayout).isFalse(); + renderObject.dy = 6; + check(renderObject.debugNeedsLayout).isTrue(); + await tester.pump(); + check(renderObject.debugNeedsLayout).isFalse(); + }); + }); } From 5928b2d7430f8cfe60573ffb7903499d073f820b Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Mon, 30 Mar 2026 20:14:18 -0700 Subject: [PATCH 17/24] text [nfc]: Have InlineIcon accept a custom (e.g. clamped) TextScaler --- lib/widgets/text.dart | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/lib/widgets/text.dart b/lib/widgets/text.dart index 5d48ef457..2c08b6573 100644 --- a/lib/widgets/text.dart +++ b/lib/widgets/text.dart @@ -691,6 +691,7 @@ class InlineIcon extends StatelessWidget { required this.icon, required this.fontSize, this.baselineType, + this.textScaler, required this.color, this.padBefore = false, this.padAfter = false, @@ -704,6 +705,11 @@ class InlineIcon extends StatelessWidget { /// If null, [localizedTextBaseline] is used. final TextBaseline? baselineType; + /// The [TextScaler] to apply. + /// + /// If null, [MediaQuery.textScalerOf] is used. + final TextScaler? textScaler; + final Color? color; final bool padBefore; final bool padAfter; @@ -726,6 +732,8 @@ class InlineIcon extends StatelessWidget { icon: icon, fontSize: fontSize, baselineType: baselineType, + // TODO(#735) remove [TextScaler.noScaling] (works around double-scale bug) + textScaler: TextScaler.noScaling, color: color, padBefore: padBefore, padAfter: padAfter, @@ -735,6 +743,7 @@ class InlineIcon extends StatelessWidget { @override Widget build(BuildContext context) { final baselineType = this.baselineType ?? localizedTextBaseline(context); + final textScaler = this.textScaler ?? MediaQuery.textScalerOf(context); final InlineIconGeometryData( :sizeFactor, @@ -742,7 +751,7 @@ class InlineIcon extends StatelessWidget { :paddingFactor, ) = InlineIconGeometryData.forIcon(icon); - final size = sizeFactor * fontSize; + final size = sizeFactor * textScaler.scale(fontSize); final effectiveBaselineOffset = switch (baselineType) { // I.e., consider the baseline to be farther up than the icon's bottom edge, From b7bb3e3912e181ff3437dc594bff48f89b6f6c46 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Tue, 31 Mar 2026 00:04:00 -0700 Subject: [PATCH 18/24] inbox, topic_list: Baseline-align markers at row end with each other Fixes #2108. And sync the size and color of the icons between the inbox and the topic list, fixing #2108. Next up, #1528: in the topic list, we'll baseline-align all of the row's content. --- lib/widgets/inbox.dart | 91 +++++++++++++++++++++++++------------ lib/widgets/text.dart | 24 ++++++++++ lib/widgets/topic_list.dart | 18 ++++---- 3 files changed, 93 insertions(+), 40 deletions(-) diff --git a/lib/widgets/inbox.dart b/lib/widgets/inbox.dart index 90ccd6079..957898379 100644 --- a/lib/widgets/inbox.dart +++ b/lib/widgets/inbox.dart @@ -367,12 +367,13 @@ class InboxDmItem extends StatelessWidget { title))), // 6 in Figma, but 8 is consistent with channel and topic rows const SizedBox(width: 8), - if (hasMention) const _IconMarker(icon: ZulipIcons.at_sign), - CounterBadge( - // TODO(design) use CounterKind.quantity, following Figma - kind: CounterBadgeKind.unread, - channelIdForBackground: null, - count: count), + InboxRowTrailingMarkers( + hasMention: hasMention, + unreadCountBadge: CounterBadge( + // TODO(design) use CounterKind.quantity, following Figma + kind: CounterBadgeKind.unread, + channelIdForBackground: null, + count: count)), ]))))); return Semantics(container: true, @@ -495,12 +496,13 @@ class InboxChannelHeaderItem extends StatelessWidget { collapsed ? ZulipIcons.chevron_down : ZulipIcons.chevron_up), ])), const SizedBox(width: 8), - if (hasMention) const _IconMarker(icon: ZulipIcons.at_sign), - CounterBadge( - // TODO(design) use CounterKind.quantity, following Figma - kind: CounterBadgeKind.unread, - channelIdForBackground: subscription.streamId, - count: count), + InboxRowTrailingMarkers( + hasMention: hasMention, + unreadCountBadge: CounterBadge( + // TODO(design) use CounterKind.quantity, following Figma + kind: CounterBadgeKind.unread, + channelIdForBackground: subscription.streamId, + count: count)), ]))))); return Semantics(container: true, @@ -603,13 +605,14 @@ class InboxTopicItem extends StatelessWidget { overflow: TextOverflow.ellipsis, topic.displayName ?? store.realmEmptyTopicDisplayName))), const SizedBox(width: 8), - if (hasMention) const _IconMarker(icon: ZulipIcons.at_sign), - if (visibilityIcon != null) _IconMarker(icon: visibilityIcon), - CounterBadge( - // TODO(design) use CounterKind.quantity, following Figma - kind: CounterBadgeKind.unread, - channelIdForBackground: streamId, - count: count), + InboxRowTrailingMarkers( + hasMention: hasMention, + visibilityIcon: visibilityIcon, + unreadCountBadge: CounterBadge( + // TODO(design) use CounterKind.quantity, following Figma + kind: CounterBadgeKind.unread, + channelIdForBackground: streamId, + count: count)), ]))))); return Semantics(container: true, @@ -617,20 +620,48 @@ class InboxTopicItem extends StatelessWidget { } } -class _IconMarker extends StatelessWidget { - const _IconMarker({required this.icon}); +/// A short, baseline-aligned row, optionally containing +/// an unread badge, @ icon, and topic visibility icon. +/// +/// This encapsulates the baseline alignment and a few style choices +/// that should be consistent between the inbox and the topic-list page. +class InboxRowTrailingMarkers extends StatelessWidget { + const InboxRowTrailingMarkers({ + super.key, + this.hasMention = false, + this.visibilityIcon, + this.unreadCountBadge, + }); - final IconData icon; + final bool hasMention; + final IconData? visibilityIcon; + final Widget? unreadCountBadge; + + Widget _buildIcon(BuildContext context, IconData icon, {required bool padAfter}) { + return InlineIcon( + icon: icon, + fontSize: 17, + textScaler: MediaQuery.textScalerOf(context).clamp(maxScaleFactor: 1.5), + color: DesignVariables.of(context).textMessage.withFadedAlpha(0.4), + padAfter: padAfter, + ); + } @override Widget build(BuildContext context) { - final designVariables = DesignVariables.of(context); - // Design for icon markers based on Figma screen: - // https://www.figma.com/file/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?type=design&node-id=224-16386&mode=design&t=JsNndFQ8fKFH0SjS-0 - return Padding( - padding: const EdgeInsetsDirectional.only(end: 4), - // This color comes from the Figma screen for the "@" marker, but not - // the topic visibility markers. - child: Icon(icon, size: 14, color: designVariables.inboxItemIconMarker)); + final hasBadge = unreadCountBadge != null; + final hasVisibility = visibilityIcon != null; + return Row( + mainAxisSize: .min, + crossAxisAlignment: .baseline, + textBaseline: localizedTextBaseline(context), + children: [ + if (hasMention) + _buildIcon(context, ZulipIcons.at_sign, + padAfter: hasVisibility || hasBadge), + if (hasVisibility) + _buildIcon(context, visibilityIcon!, padAfter: hasBadge), + ?unreadCountBadge, + ]); } } diff --git a/lib/widgets/text.dart b/lib/widgets/text.dart index 2c08b6573..4736fbfa7 100644 --- a/lib/widgets/text.dart +++ b/lib/widgets/text.dart @@ -651,6 +651,30 @@ class InlineIconGeometryData { // Values are ad hoc unless otherwise specified. static final Map _inlineIconGeometries = { + ZulipIcons.at_sign: InlineIconGeometryData._( + sizeFactor: 16 / 17, + alphabeticBaselineFactor: 3 / 16, + paddingFactor: 1 / 4, + ), + + ZulipIcons.mute: InlineIconGeometryData._( + sizeFactor: 16 / 17, + alphabeticBaselineFactor: 3 / 16, + paddingFactor: 1 / 4, + ), + + ZulipIcons.unmute: InlineIconGeometryData._( + sizeFactor: 16 / 17, + alphabeticBaselineFactor: 3 / 16, + paddingFactor: 1 / 4, + ), + + ZulipIcons.follow: InlineIconGeometryData._( + sizeFactor: 16 / 17, + alphabeticBaselineFactor: 3 / 16, + paddingFactor: 1 / 4, + ), + ZulipIcons.globe: InlineIconGeometryData._( sizeFactor: 0.8, alphabeticBaselineFactor: 1 / 8, diff --git a/lib/widgets/topic_list.dart b/lib/widgets/topic_list.dart index f77e7c065..8e5940e5a 100644 --- a/lib/widgets/topic_list.dart +++ b/lib/widgets/topic_list.dart @@ -10,6 +10,7 @@ import 'action_sheet.dart'; import 'app_bar.dart'; import 'color.dart'; import 'icons.dart'; +import 'inbox.dart'; import 'message_list.dart'; import 'page.dart'; import 'store.dart'; @@ -299,19 +300,16 @@ class _TopicItem extends StatelessWidget { maxLines: 3, overflow: TextOverflow.ellipsis, topic.unresolve().displayName ?? store.realmEmptyTopicDisplayName))), - Opacity(opacity: opacity, child: Row( - spacing: 4, - children: [ - if (hasMention) const _IconMarker(icon: ZulipIcons.at_sign), - if (visibilityIcon != null) _IconMarker(icon: visibilityIcon), - if (unreadCount > 0) + Opacity(opacity: opacity, + child: InboxRowTrailingMarkers( + hasMention: hasMention, + visibilityIcon: visibilityIcon, + unreadCountBadge: unreadCount == 0 ? null : CounterBadge( kind: CounterBadgeKind.unread, count: unreadCount, - channelIdForBackground: null), - ])), - ])), - ))); + channelIdForBackground: null))), + ]))))); } } From c0d236d39d78147d9d37a70e018c79b1db2a7cc3 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Tue, 31 Mar 2026 00:15:44 -0700 Subject: [PATCH 19/24] topic_list: Baseline-align topic-row content Fixes #1528. --- lib/widgets/text.dart | 25 +++++++++ lib/widgets/topic_list.dart | 90 ++++++++++++------------------- test/widgets/topic_list_test.dart | 16 +++--- 3 files changed, 70 insertions(+), 61 deletions(-) diff --git a/lib/widgets/text.dart b/lib/widgets/text.dart index 4736fbfa7..74852e580 100644 --- a/lib/widgets/text.dart +++ b/lib/widgets/text.dart @@ -651,6 +651,12 @@ class InlineIconGeometryData { // Values are ad hoc unless otherwise specified. static final Map _inlineIconGeometries = { + ZulipIcons.check: InlineIconGeometryData._( + sizeFactor: 1, + alphabeticBaselineFactor: 3 / 16, + paddingFactor: 1 / 4, + ), + ZulipIcons.at_sign: InlineIconGeometryData._( sizeFactor: 16 / 17, alphabeticBaselineFactor: 3 / 16, @@ -719,6 +725,7 @@ class InlineIcon extends StatelessWidget { required this.color, this.padBefore = false, this.padAfter = false, + this.visible = true, }); final IconData icon; @@ -738,6 +745,11 @@ class InlineIcon extends StatelessWidget { final bool padBefore; final bool padAfter; + /// Whether the icon is visible. + /// + /// Pass false to hide the icon but maintain its size. + final bool visible; + /// Creates an [InlineIcon] wrapped in a [WidgetSpan]. /// /// The [WidgetSpan] has [PlaceholderAlignment.baseline]. @@ -748,6 +760,7 @@ class InlineIcon extends StatelessWidget { required Color? color, bool padBefore = false, bool padAfter = false, + bool visible = true, }) { return WidgetSpan( alignment: PlaceholderAlignment.baseline, @@ -761,6 +774,7 @@ class InlineIcon extends StatelessWidget { color: color, padBefore: padBefore, padAfter: padAfter, + visible: visible, )); } @@ -802,6 +816,17 @@ class InlineIcon extends StatelessWidget { child: result); } + result = Visibility( + visible: visible, + + // To set [maintainSize] true, apparently we have to set + // [maintainState] and [maintainAnimation] true too; sure. + maintainState: true, + maintainAnimation: true, + maintainSize: true, + + child: result); + return result; } } diff --git a/lib/widgets/topic_list.dart b/lib/widgets/topic_list.dart index 8e5940e5a..f5f9c8969 100644 --- a/lib/widgets/topic_list.dart +++ b/lib/widgets/topic_list.dart @@ -272,60 +272,40 @@ class _TopicItem extends StatelessWidget { constraints: BoxConstraints(minHeight: 40), child: Padding( padding: EdgeInsetsDirectional.fromSTEB(6, 4, 12, 4), - child: Row( - spacing: 8, - // In the Figma design, the text and icons on the topic item row - // are aligned to the start on the cross axis - // (i.e., `align-items: flex-start`). The icons are padded down - // 2px relative to the start, to visibly sit on the baseline. - // To account for scaled text, we align everything on the row - // to [CrossAxisAlignment.center] instead ([Row]'s default), - // like we do for the topic items on the inbox page. - // TODO(#1528): align to baseline (and therefore to first line of - // topic name), but with adjustment for icons - // CZO discussion: - // https://chat.zulip.org/#narrow/channel/243-mobile-team/topic/topic.20list.20item.20alignment/near/2173252 - children: [ - // A null [Icon.icon] makes a blank space. - _IconMarker(icon: topic.isResolved ? ZulipIcons.check : null), - Expanded(child: Opacity( - opacity: opacity, - child: Text( - style: TextStyle( - fontSize: 17, - height: 20 / 17, - fontStyle: topic.displayName == null ? FontStyle.italic : null, - color: designVariables.textMessage, - ), - maxLines: 3, - overflow: TextOverflow.ellipsis, - topic.unresolve().displayName ?? store.realmEmptyTopicDisplayName))), - Opacity(opacity: opacity, - child: InboxRowTrailingMarkers( - hasMention: hasMention, - visibilityIcon: visibilityIcon, - unreadCountBadge: unreadCount == 0 ? null : - CounterBadge( - kind: CounterBadgeKind.unread, - count: unreadCount, - channelIdForBackground: null))), - ]))))); - } -} - -class _IconMarker extends StatelessWidget { - const _IconMarker({required this.icon}); - - final IconData? icon; - - @override - Widget build(BuildContext context) { - final designVariables = DesignVariables.of(context); - final textScaler = MediaQuery.textScalerOf(context); - // Since we align the icons to [CrossAxisAlignment.center], the top padding - // from the Figma design is omitted. - return Icon(icon, - size: textScaler.clamp(maxScaleFactor: 1.5).scale(16), - color: designVariables.textMessage.withFadedAlpha(0.4)); + child: Center( + widthFactor: 1, + child: Row( + spacing: 8, + crossAxisAlignment: .baseline, + textBaseline: localizedTextBaseline(context), + children: [ + InlineIcon( + visible: topic.isResolved, + icon: ZulipIcons.check, + fontSize: 17, + textScaler: MediaQuery.textScalerOf(context).clamp(maxScaleFactor: 1.5), + color: DesignVariables.of(context).textMessage.withFadedAlpha(0.4)), + Expanded(child: Opacity( + opacity: opacity, + child: Text( + style: TextStyle( + fontSize: 17, + height: 20 / 17, + fontStyle: topic.displayName == null ? FontStyle.italic : null, + color: designVariables.textMessage, + ), + maxLines: 3, + overflow: TextOverflow.ellipsis, + topic.unresolve().displayName ?? store.realmEmptyTopicDisplayName))), + Opacity(opacity: opacity, + child: InboxRowTrailingMarkers( + hasMention: hasMention, + visibilityIcon: visibilityIcon, + unreadCountBadge: unreadCount == 0 ? null : + CounterBadge( + kind: CounterBadgeKind.unread, + count: unreadCount, + channelIdForBackground: null))), + ])))))); } } diff --git a/test/widgets/topic_list_test.dart b/test/widgets/topic_list_test.dart index 0f380264c..d38918aa3 100644 --- a/test/widgets/topic_list_test.dart +++ b/test/widgets/topic_list_test.dart @@ -11,6 +11,7 @@ import 'package:zulip/model/store.dart'; import 'package:zulip/widgets/app_bar.dart'; import 'package:zulip/widgets/icons.dart'; import 'package:zulip/widgets/message_list.dart'; +import 'package:zulip/widgets/text.dart'; import 'package:zulip/widgets/topic_list.dart'; import '../api/fake_api.dart'; @@ -177,6 +178,10 @@ void main() { of: topicItemFinder.at(index), matching: finder); + InlineIcon checkmarkIconAt(WidgetTester tester, int index) => + tester.widget(findInTopicItemAt(index, find.byWidgetPredicate( + (w) => w is InlineIcon && w.icon == ZulipIcons.check))); + testWidgets('sort topics by maxId', (tester) async { await prepare(tester, topics: [ eg.getChannelTopicsEntry(name: 'A', maxId: 3), @@ -255,9 +260,10 @@ void main() { await prepare(tester, channel: channel, topics: [eg.getChannelTopicsEntry(maxId: 109, name: 'foo')], messages: messages); + await tester.longPress(topicItemFinder); await tester.pump(Duration(milliseconds: 150)); // bottom-sheet animation - check(findInTopicItemAt(0, find.byIcon(ZulipIcons.check))).findsNothing(); + check(checkmarkIconAt(tester, 0).visible).isFalse(); check(findInTopicItemAt(0, find.text('foo'))).findsOne(); connection.prepare(json: {}); @@ -270,7 +276,7 @@ void main() { newTopic: eg.t('foo').resolve(), propagateMode: .changeAll)); await tester.pump(); - check(findInTopicItemAt(0, find.byIcon(ZulipIcons.check))).findsOne(); + check(checkmarkIconAt(tester, 0).visible).isTrue(); check(findInTopicItemAt(0, find.text('foo'))).findsOne(); }); @@ -286,12 +292,10 @@ void main() { check(findInTopicItemAt(0, find.text('✔ resolved'))).findsNothing(); check(findInTopicItemAt(0, find.text('resolved'))).findsOne(); - check(findInTopicItemAt(0, find.byIcon(ZulipIcons.check).hitTestable())) - .findsOne(); + check(checkmarkIconAt(tester, 0).visible).isTrue(); check(findInTopicItemAt(1, find.text('unresolved'))).findsOne(); - check(findInTopicItemAt(1, find.byType(Icon)).hitTestable()) - .findsNothing(); + check(checkmarkIconAt(tester, 1).visible).isFalse(); }); testWidgets('handle empty topics', (tester) async { From 880b2f833a3d18045bf9f2c570565651fdda9b7c Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Tue, 31 Mar 2026 00:49:31 -0700 Subject: [PATCH 20/24] inbox: Use checkmark icon for resolved topics, like in topic list --- lib/widgets/inbox.dart | 23 +++++++++++++++++++++-- lib/widgets/topic_list.dart | 2 ++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/lib/widgets/inbox.dart b/lib/widgets/inbox.dart index 957898379..756e8c98d 100644 --- a/lib/widgets/inbox.dart +++ b/lib/widgets/inbox.dart @@ -590,8 +590,27 @@ class InboxTopicItem extends StatelessWidget { topic: topic, someMessageIdInTopic: lastUnreadId), child: ConstrainedBox(constraints: const BoxConstraints(minHeight: 34), - child: Padding(padding: const EdgeInsetsDirectional.fromSTEB(50, 0, 12, 0), + child: Padding(padding: const EdgeInsetsDirectional.fromSTEB(0, 0, 12, 0), child: Row(crossAxisAlignment: CrossAxisAlignment.center, children: [ + SizedBox( + width: 42, + child: Align( + // When the system text-size setting rises, + // let the checkmark icon grow startward into the margin + // so the topic text stays aligned with channel-header text. + // (Note: "center" in .centerEnd is inert: + // this Align shrink-wraps to its child's height.) + alignment: .centerEnd, + child: topic.isResolved + ? InlineIcon( + // Compare icon style in the topic list; probably these + // should stay in sync. + icon: ZulipIcons.check, + fontSize: 17, + textScaler: MediaQuery.textScalerOf(context).clamp(maxScaleFactor: 1.5), + color: DesignVariables.of(context).textMessage.withFadedAlpha(0.4)) + : null)), + SizedBox(width: 8), Expanded(child: Padding( padding: const EdgeInsets.symmetric(vertical: 4), child: Text( @@ -603,7 +622,7 @@ class InboxTopicItem extends StatelessWidget { ), maxLines: 3, overflow: TextOverflow.ellipsis, - topic.displayName ?? store.realmEmptyTopicDisplayName))), + topic.unresolve().displayName ?? store.realmEmptyTopicDisplayName))), const SizedBox(width: 8), InboxRowTrailingMarkers( hasMention: hasMention, diff --git a/lib/widgets/topic_list.dart b/lib/widgets/topic_list.dart index f5f9c8969..bb57e42fc 100644 --- a/lib/widgets/topic_list.dart +++ b/lib/widgets/topic_list.dart @@ -280,6 +280,8 @@ class _TopicItem extends StatelessWidget { textBaseline: localizedTextBaseline(context), children: [ InlineIcon( + // Compare icon style in the inbox; probably these + // should stay in sync. visible: topic.isResolved, icon: ZulipIcons.check, fontSize: 17, From 2d21d8db15bca9f8cead74d00982619c827364a5 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Tue, 31 Mar 2026 00:51:07 -0700 Subject: [PATCH 21/24] inbox: Baseline-align topic-row content As in Figma: https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=16056-6335&m=dev and as we just did in the topic-list page in a recent commit. --- lib/widgets/inbox.dart | 87 ++++++++++++++++++++++-------------------- 1 file changed, 46 insertions(+), 41 deletions(-) diff --git a/lib/widgets/inbox.dart b/lib/widgets/inbox.dart index 756e8c98d..9ec453fbb 100644 --- a/lib/widgets/inbox.dart +++ b/lib/widgets/inbox.dart @@ -591,48 +591,53 @@ class InboxTopicItem extends StatelessWidget { someMessageIdInTopic: lastUnreadId), child: ConstrainedBox(constraints: const BoxConstraints(minHeight: 34), child: Padding(padding: const EdgeInsetsDirectional.fromSTEB(0, 0, 12, 0), - child: Row(crossAxisAlignment: CrossAxisAlignment.center, children: [ - SizedBox( - width: 42, - child: Align( - // When the system text-size setting rises, - // let the checkmark icon grow startward into the margin - // so the topic text stays aligned with channel-header text. - // (Note: "center" in .centerEnd is inert: - // this Align shrink-wraps to its child's height.) - alignment: .centerEnd, - child: topic.isResolved - ? InlineIcon( - // Compare icon style in the topic list; probably these - // should stay in sync. - icon: ZulipIcons.check, + child: Center( + widthFactor: 1, + child: Row( + crossAxisAlignment: .baseline, + textBaseline: localizedTextBaseline(context), + children: [ + SizedBox( + width: 42, + child: Align( + // When the system text-size setting rises, + // let the checkmark icon grow startward into the margin + // so the topic text stays aligned with channel-header text. + // (Note: "center" in .centerEnd is inert: + // this Align shrink-wraps to its child's height.) + alignment: .centerEnd, + child: topic.isResolved + ? InlineIcon( + // Compare icon style in the topic list; probably these + // should stay in sync. + icon: ZulipIcons.check, + fontSize: 17, + textScaler: MediaQuery.textScalerOf(context).clamp(maxScaleFactor: 1.5), + color: DesignVariables.of(context).textMessage.withFadedAlpha(0.4)) + : null)), + SizedBox(width: 8), + Expanded(child: Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Text( + style: TextStyle( fontSize: 17, - textScaler: MediaQuery.textScalerOf(context).clamp(maxScaleFactor: 1.5), - color: DesignVariables.of(context).textMessage.withFadedAlpha(0.4)) - : null)), - SizedBox(width: 8), - Expanded(child: Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: Text( - style: TextStyle( - fontSize: 17, - height: (20 / 17), - fontStyle: topic.displayName == null ? FontStyle.italic : null, - color: designVariables.textMessage, - ), - maxLines: 3, - overflow: TextOverflow.ellipsis, - topic.unresolve().displayName ?? store.realmEmptyTopicDisplayName))), - const SizedBox(width: 8), - InboxRowTrailingMarkers( - hasMention: hasMention, - visibilityIcon: visibilityIcon, - unreadCountBadge: CounterBadge( - // TODO(design) use CounterKind.quantity, following Figma - kind: CounterBadgeKind.unread, - channelIdForBackground: streamId, - count: count)), - ]))))); + height: (20 / 17), + fontStyle: topic.displayName == null ? FontStyle.italic : null, + color: designVariables.textMessage, + ), + maxLines: 3, + overflow: TextOverflow.ellipsis, + topic.unresolve().displayName ?? store.realmEmptyTopicDisplayName))), + const SizedBox(width: 8), + InboxRowTrailingMarkers( + hasMention: hasMention, + visibilityIcon: visibilityIcon, + unreadCountBadge: CounterBadge( + // TODO(design) use CounterKind.quantity, following Figma + kind: CounterBadgeKind.unread, + channelIdForBackground: streamId, + count: count)), + ])))))); return Semantics(container: true, child: result); From 5edc4abb0b1c54a9df8a9be0145d803cab473f0f Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Thu, 9 Apr 2026 14:01:56 -0700 Subject: [PATCH 22/24] inbox: Only show chevron icon on collapsed channel rows, not also uncollapsed As specced by Vlad: https://chat.zulip.org/#narrow/channel/530-mobile-design/topic/channel.20folders.20in.20inbox.3A.20design/near/2426869 --- lib/widgets/inbox.dart | 12 ++++++------ test/widgets/home_test.dart | 19 ++++++++----------- test/widgets/inbox_test.dart | 11 +++-------- 3 files changed, 17 insertions(+), 25 deletions(-) diff --git a/lib/widgets/inbox.dart b/lib/widgets/inbox.dart index 9ec453fbb..17d2e80a9 100644 --- a/lib/widgets/inbox.dart +++ b/lib/widgets/inbox.dart @@ -488,12 +488,12 @@ class InboxChannelHeaderItem extends StatelessWidget { maxLines: 1, overflow: TextOverflow.ellipsis, subscription.name)), - const SizedBox(width: 6), - Icon(size: 20, - color: designVariables.textMessage.withFadedAlpha(0.5), - // TODO(design) hide icon when uncollapsed? - // Discussion: https://chat.zulip.org/#narrow/channel/530-mobile-design/topic/channel.20folders.20in.20inbox.3A.20design/near/2422785 - collapsed ? ZulipIcons.chevron_down : ZulipIcons.chevron_up), + if (collapsed) ...[ + const SizedBox(width: 6), + Icon(size: 20, + color: designVariables.textMessage.withFadedAlpha(0.5), + ZulipIcons.chevron_down), + ], ])), const SizedBox(width: 8), InboxRowTrailingMarkers( diff --git a/test/widgets/home_test.dart b/test/widgets/home_test.dart index 2c5ab0a1b..31f2f4a78 100644 --- a/test/widgets/home_test.dart +++ b/test/widgets/home_test.dart @@ -118,29 +118,26 @@ void main () { await store.addMessage(eg.streamMessage(stream: channel)); await tester.pump(); - Finder findIcon(IconData icon) => - find.widgetWithIcon(InboxChannelHeaderItem, icon); + final findChevron = find.widgetWithIcon( + InboxChannelHeaderItem, ZulipIcons.chevron_down); - check(findIcon(ZulipIcons.chevron_up)).findsOne(); - check(findIcon(ZulipIcons.chevron_down)).findsNothing(); + // Uncollapsed: no chevron visible. + check(findChevron).findsNothing(); // Collapsing the header updates inbox's internal state. - await tester.tap(findIcon(ZulipIcons.chevron_up)); + await tester.tap(find.byType(InboxChannelHeaderItem)); await tester.pump(); - check(findIcon(ZulipIcons.chevron_up)).findsNothing(); - check(findIcon(ZulipIcons.chevron_down)).findsOne(); + check(findChevron).findsOne(); // Switch to channels view. await tester.tap(find.byIcon(ZulipIcons.hash_italic)); await tester.pump(); - check(findIcon(ZulipIcons.chevron_up)).findsNothing(); - check(findIcon(ZulipIcons.chevron_down)).findsNothing(); + check(findChevron).findsNothing(); // The header should remain collapsed when we return to the inbox. await tester.tap(find.byIcon(ZulipIcons.inbox)); await tester.pump(); - check(findIcon(ZulipIcons.chevron_up)).findsNothing(); - check(findIcon(ZulipIcons.chevron_down)).findsOne(); + check(findChevron).findsOne(); }); testWidgets('update app bar title and actions when switching between views', (tester) async { diff --git a/test/widgets/inbox_test.dart b/test/widgets/inbox_test.dart index faa90e75f..a67bd1cb9 100644 --- a/test/widgets/inbox_test.dart +++ b/test/widgets/inbox_test.dart @@ -192,8 +192,8 @@ void main() { if (expectCollapsed != null) { check(find.descendant( of: findHeader, - matching: find.byIcon( - expectCollapsed ? ZulipIcons.chevron_down : ZulipIcons.chevron_up))).findsOne(); + matching: find.byIcon(ZulipIcons.chevron_down))) + .findsExactly(expectCollapsed ? 1 : 0); // TODO could test bar background (not finding a way just now to // expect a gradient to be painted) @@ -632,12 +632,7 @@ void main() { group('stream section', () { Future tapCollapseIcon(WidgetTester tester, Subscription subscription) async { checkChannelHeader(tester, subscription); - await tester.tap(find.descendant( - of: findChannelHeader(subscription.streamId), - matching: find.byWidgetPredicate((widget) => - widget is Icon - && (widget.icon == ZulipIcons.chevron_up - || widget.icon == ZulipIcons.chevron_down)))); + await tester.tap(findChannelHeader(subscription.streamId)); await tester.pump(); } From edd0042a0d85664dea1ade5ad14610d7150f1e8f Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Sun, 12 Apr 2026 16:58:22 -0700 Subject: [PATCH 23/24] inbox, topic_list [nfc]: Pull out a const for consistency of a font size All these pieces of text (except one that will in future, noted with a TODO) are on rows that use InboxRowTrailingMarkers and therefore should match the font size it uses for its icons. --- lib/widgets/inbox.dart | 30 +++++++++++++++++++----------- lib/widgets/topic_list.dart | 6 +++--- 2 files changed, 22 insertions(+), 14 deletions(-) diff --git a/lib/widgets/inbox.dart b/lib/widgets/inbox.dart index 17d2e80a9..3dbbb9ffe 100644 --- a/lib/widgets/inbox.dart +++ b/lib/widgets/inbox.dart @@ -282,6 +282,11 @@ class InboxFolderHeaderItem extends StatelessWidget { @override Widget build(BuildContext context) { final designVariables = DesignVariables.of(context); + + // TODO(#2259) actually show the trailing markers; + // we use this just to anticipate doing that. + const fontSize = InboxRowTrailingMarkers.fontSize; + Widget result = DecoratedBox( decoration: BoxDecoration( color: designVariables.background, // TODO(design) check if this is the right variable @@ -296,9 +301,9 @@ class InboxFolderHeaderItem extends StatelessWidget { overflow: .ellipsis, style: TextStyle( color: designVariables.folderText, - fontSize: 17, - height: 20 / 17, - letterSpacing: proportionalLetterSpacing(context, 0.02, baseFontSize: 17), + fontSize: fontSize, + height: 20 / fontSize, + letterSpacing: proportionalLetterSpacing(context, 0.02, baseFontSize: fontSize), ).merge(weightVariableTextStyle(context, wght: 700)), label.toUpperCase())), ]))); @@ -358,8 +363,8 @@ class InboxDmItem extends StatelessWidget { padding: const EdgeInsets.symmetric(vertical: 3), child: Text( style: TextStyle( - fontSize: 17, - height: (19 / 17), + fontSize: InboxRowTrailingMarkers.fontSize, + height: (19 / InboxRowTrailingMarkers.fontSize), color: designVariables.textMessage, ), maxLines: 2, @@ -481,8 +486,8 @@ class InboxChannelHeaderItem extends StatelessWidget { Flexible( child: Text( style: TextStyle( - fontSize: 17, - height: (20 / 17), + fontSize: InboxRowTrailingMarkers.fontSize, + height: (20 / InboxRowTrailingMarkers.fontSize), color: designVariables.textMessage, ).merge(weightVariableTextStyle(context, wght: 600)), maxLines: 1, @@ -611,7 +616,7 @@ class InboxTopicItem extends StatelessWidget { // Compare icon style in the topic list; probably these // should stay in sync. icon: ZulipIcons.check, - fontSize: 17, + fontSize: InboxRowTrailingMarkers.fontSize, textScaler: MediaQuery.textScalerOf(context).clamp(maxScaleFactor: 1.5), color: DesignVariables.of(context).textMessage.withFadedAlpha(0.4)) : null)), @@ -620,8 +625,8 @@ class InboxTopicItem extends StatelessWidget { padding: const EdgeInsets.symmetric(vertical: 4), child: Text( style: TextStyle( - fontSize: 17, - height: (20 / 17), + fontSize: InboxRowTrailingMarkers.fontSize, + height: (20 / InboxRowTrailingMarkers.fontSize), fontStyle: topic.displayName == null ? FontStyle.italic : null, color: designVariables.textMessage, ), @@ -661,10 +666,13 @@ class InboxRowTrailingMarkers extends StatelessWidget { final IconData? visibilityIcon; final Widget? unreadCountBadge; + /// The font size used for the row's text, and therefore for the icons here. + static const fontSize = 17.0; + Widget _buildIcon(BuildContext context, IconData icon, {required bool padAfter}) { return InlineIcon( icon: icon, - fontSize: 17, + fontSize: fontSize, textScaler: MediaQuery.textScalerOf(context).clamp(maxScaleFactor: 1.5), color: DesignVariables.of(context).textMessage.withFadedAlpha(0.4), padAfter: padAfter, diff --git a/lib/widgets/topic_list.dart b/lib/widgets/topic_list.dart index bb57e42fc..83be9e381 100644 --- a/lib/widgets/topic_list.dart +++ b/lib/widgets/topic_list.dart @@ -284,15 +284,15 @@ class _TopicItem extends StatelessWidget { // should stay in sync. visible: topic.isResolved, icon: ZulipIcons.check, - fontSize: 17, + fontSize: InboxRowTrailingMarkers.fontSize, textScaler: MediaQuery.textScalerOf(context).clamp(maxScaleFactor: 1.5), color: DesignVariables.of(context).textMessage.withFadedAlpha(0.4)), Expanded(child: Opacity( opacity: opacity, child: Text( style: TextStyle( - fontSize: 17, - height: 20 / 17, + fontSize: InboxRowTrailingMarkers.fontSize, + height: 20 / InboxRowTrailingMarkers.fontSize, fontStyle: topic.displayName == null ? FontStyle.italic : null, color: designVariables.textMessage, ), From 840f50bb5fb66ae63c21a5ea0f7d15eb9cb092c6 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Sun, 12 Apr 2026 18:14:31 -0700 Subject: [PATCH 24/24] inbox, topic_list [nfc]: Pull out InboxRowMarkerIcon helper widget --- lib/widgets/inbox.dart | 48 ++++++++++++++++++++++++++----------- lib/widgets/topic_list.dart | 10 ++------ 2 files changed, 36 insertions(+), 22 deletions(-) diff --git a/lib/widgets/inbox.dart b/lib/widgets/inbox.dart index 3dbbb9ffe..607c3108e 100644 --- a/lib/widgets/inbox.dart +++ b/lib/widgets/inbox.dart @@ -612,13 +612,7 @@ class InboxTopicItem extends StatelessWidget { // this Align shrink-wraps to its child's height.) alignment: .centerEnd, child: topic.isResolved - ? InlineIcon( - // Compare icon style in the topic list; probably these - // should stay in sync. - icon: ZulipIcons.check, - fontSize: InboxRowTrailingMarkers.fontSize, - textScaler: MediaQuery.textScalerOf(context).clamp(maxScaleFactor: 1.5), - color: DesignVariables.of(context).textMessage.withFadedAlpha(0.4)) + ? InboxRowMarkerIcon(icon: ZulipIcons.check) : null)), SizedBox(width: 8), Expanded(child: Padding( @@ -649,6 +643,38 @@ class InboxTopicItem extends StatelessWidget { } } +/// An [InlineIcon] styled for use as a marker in inbox rows. +/// +/// This encapsulates style details that should stay in sync +/// across the inbox and topic-list pages. +class InboxRowMarkerIcon extends StatelessWidget { + const InboxRowMarkerIcon({ + super.key, + required this.icon, + this.visible = true, + this.padBefore = false, + this.padAfter = false, + }); + + final IconData icon; + final bool visible; + final bool padBefore; + final bool padAfter; + + @override + Widget build(BuildContext context) { + return InlineIcon( + icon: icon, + fontSize: InboxRowTrailingMarkers.fontSize, + textScaler: MediaQuery.textScalerOf(context).clamp(maxScaleFactor: 1.5), + color: DesignVariables.of(context).textMessage.withFadedAlpha(0.4), + visible: visible, + padBefore: padBefore, + padAfter: padAfter, + ); + } +} + /// A short, baseline-aligned row, optionally containing /// an unread badge, @ icon, and topic visibility icon. /// @@ -670,13 +696,7 @@ class InboxRowTrailingMarkers extends StatelessWidget { static const fontSize = 17.0; Widget _buildIcon(BuildContext context, IconData icon, {required bool padAfter}) { - return InlineIcon( - icon: icon, - fontSize: fontSize, - textScaler: MediaQuery.textScalerOf(context).clamp(maxScaleFactor: 1.5), - color: DesignVariables.of(context).textMessage.withFadedAlpha(0.4), - padAfter: padAfter, - ); + return InboxRowMarkerIcon(icon: icon, padAfter: padAfter); } @override diff --git a/lib/widgets/topic_list.dart b/lib/widgets/topic_list.dart index 83be9e381..2eac29635 100644 --- a/lib/widgets/topic_list.dart +++ b/lib/widgets/topic_list.dart @@ -8,7 +8,6 @@ import '../model/topics.dart'; import '../model/unreads.dart'; import 'action_sheet.dart'; import 'app_bar.dart'; -import 'color.dart'; import 'icons.dart'; import 'inbox.dart'; import 'message_list.dart'; @@ -279,14 +278,9 @@ class _TopicItem extends StatelessWidget { crossAxisAlignment: .baseline, textBaseline: localizedTextBaseline(context), children: [ - InlineIcon( - // Compare icon style in the inbox; probably these - // should stay in sync. - visible: topic.isResolved, + InboxRowMarkerIcon( icon: ZulipIcons.check, - fontSize: InboxRowTrailingMarkers.fontSize, - textScaler: MediaQuery.textScalerOf(context).clamp(maxScaleFactor: 1.5), - color: DesignVariables.of(context).textMessage.withFadedAlpha(0.4)), + visible: topic.isResolved), Expanded(child: Opacity( opacity: opacity, child: Text(