From 6d078e38640347102571e79090830e52896a0147 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Wed, 9 Jul 2025 14:01:42 -0700 Subject: [PATCH 1/7] action_sheet: Choose "Close" and "Cancel" consistently for bottom sheets This fixes the two inconsistencies flagged in discussion: https://chat.zulip.org/#narrow/channel/530-mobile-design/topic/bottom.20sheet.20.22Cancel.22.2F.22Close.22.20button/near/2216116 > I think it's reasonable to have both labels, but I think we should > choose them differently than now: > > - "Cancel" when the sheet is about doing an action: [etc.] > > - "Close" when the sheet just presents information or nav options: > [etc.] --- lib/widgets/action_sheet.dart | 26 ++++++++++++++++++++++---- lib/widgets/emoji_reaction.dart | 2 +- lib/widgets/home.dart | 3 ++- test/widgets/home_test.dart | 6 +++--- 4 files changed, 28 insertions(+), 9 deletions(-) diff --git a/lib/widgets/action_sheet.dart b/lib/widgets/action_sheet.dart index 6b280df6ee..deec4b5952 100644 --- a/lib/widgets/action_sheet.dart +++ b/lib/widgets/action_sheet.dart @@ -92,7 +92,7 @@ void _showActionSheet( child: SingleChildScrollView( padding: const EdgeInsets.symmetric(vertical: 8), child: MenuButtonsShape(buttons: optionButtons)))), - const ActionSheetCancelButton(), + const BottomSheetDismissButton(style: BottomSheetDismissButtonStyle.cancel), ]))), ])))); }); @@ -160,12 +160,22 @@ abstract class ActionSheetMenuItemButton extends StatelessWidget { } } -class ActionSheetCancelButton extends StatelessWidget { - const ActionSheetCancelButton({super.key}); +/// A stretched gray "Cancel" / "Close" button for the bottom of a bottom sheet. +class BottomSheetDismissButton extends StatelessWidget { + const BottomSheetDismissButton({super.key, required this.style}); + + final BottomSheetDismissButtonStyle style; @override Widget build(BuildContext context) { final designVariables = DesignVariables.of(context); + final zulipLocalizations = ZulipLocalizations.of(context); + + final label = switch (style) { + BottomSheetDismissButtonStyle.cancel => zulipLocalizations.dialogCancel, + BottomSheetDismissButtonStyle.close => zulipLocalizations.dialogClose, + }; + return TextButton( style: TextButton.styleFrom( minimumSize: const Size.fromHeight(44), @@ -180,12 +190,20 @@ class ActionSheetCancelButton extends StatelessWidget { onPressed: () { Navigator.pop(context); }, - child: Text(ZulipLocalizations.of(context).dialogCancel, + child: Text(label, style: const TextStyle(fontSize: 20, height: 24 / 20) .merge(weightVariableTextStyle(context, wght: 600)))); } } +enum BottomSheetDismissButtonStyle { + /// The "Cancel" label, for action sheets. + cancel, + + /// The "Close" label, for bottom sheets that are read-only or for navigation. + close, +} + /// Show a sheet of actions you can take on a channel. /// /// Needs a [PageRoot] ancestor. diff --git a/lib/widgets/emoji_reaction.dart b/lib/widgets/emoji_reaction.dart index 3c26361d3a..bc22b4d4a5 100644 --- a/lib/widgets/emoji_reaction.dart +++ b/lib/widgets/emoji_reaction.dart @@ -518,7 +518,7 @@ class _EmojiPickerState extends State with PerAccountStoreAwareStat states.contains(WidgetState.pressed) ? designVariables.contextMenuItemBg.withFadedAlpha(0.20) : Colors.transparent)), - child: Text(zulipLocalizations.dialogClose, + child: Text(zulipLocalizations.dialogCancel, style: const TextStyle(fontSize: 20, height: 30 / 20))), ])), Expanded(child: InsetShadowBox( diff --git a/lib/widgets/home.dart b/lib/widgets/home.dart index a1dea0dff8..62c09c0857 100644 --- a/lib/widgets/home.dart +++ b/lib/widgets/home.dart @@ -324,7 +324,8 @@ void _showMainMenu(BuildContext context, { child: AnimatedScaleOnTap( scaleEnd: 0.95, duration: Duration(milliseconds: 100), - child: ActionSheetCancelButton())), + child: BottomSheetDismissButton( + style: BottomSheetDismissButtonStyle.close))), ]))); }); } diff --git a/test/widgets/home_test.dart b/test/widgets/home_test.dart index bf207155b7..5a8d3cca33 100644 --- a/test/widgets/home_test.dart +++ b/test/widgets/home_test.dart @@ -230,7 +230,7 @@ void main () { await tapOpenMenuAndAwait(tester); checkIconSelected(tester, inboxMenuIconFinder); checkIconNotSelected(tester, channelsMenuIconFinder); - await tapButtonAndAwaitTransition(tester, find.text('Cancel')); + await tapButtonAndAwaitTransition(tester, find.text('Close')); await tester.tap(find.byIcon(ZulipIcons.hash_italic)); await tester.pump(); @@ -264,10 +264,10 @@ void main () { await tapButtonAndAwaitTransition(tester, channelsMenuIconFinder); }); - testWidgets('cancel button dismisses the menu', (tester) async { + testWidgets('close button dismisses the menu', (tester) async { await prepare(tester); await tapOpenMenuAndAwait(tester); - await tapButtonAndAwaitTransition(tester, find.text('Cancel')); + await tapButtonAndAwaitTransition(tester, find.text('Close')); }); testWidgets('menu buttons dismiss the menu', (tester) async { From 203a6a53683993e5e67950a683d534ad140b7b37 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Wed, 9 Jul 2025 15:55:23 -0700 Subject: [PATCH 2/7] icons: Add see_who_reacted, from Vlad on CZO See Vlad's SVG on CZO: https://chat.zulip.org/#narrow/channel/530-mobile-design/topic/View.20Reactions.20ModalBottomSheet/near/2025350 --- assets/icons/ZulipIcons.ttf | Bin 16076 -> 16528 bytes assets/icons/see_who_reacted.svg | 6 ++++++ lib/widgets/icons.dart | 27 +++++++++++++++------------ 3 files changed, 21 insertions(+), 12 deletions(-) create mode 100644 assets/icons/see_who_reacted.svg diff --git a/assets/icons/ZulipIcons.ttf b/assets/icons/ZulipIcons.ttf index 85f393019a00470c3f77ab60b05ae32b1863547b..355d1e8dca9a8f48bc4f52dd6939855dba942a45 100644 GIT binary patch delta 2391 zcmb7GOH3SP9RGi_Pxb}7%r0zOXxUl80%c*?_Y2tN4W&tIIW+}@1*)ZBi+yO#)=Tx! zgYCwoYNClT)mRT&+UlVvvVHUbNPv2QN)L7!%One|C_XcyWi{{J-z}zaKNh z+G}sUE{Y_G$WG_TK*_Vg{sctlabOk3F|2O^!TE*d({~>JwGMle z?BnR-bY?1iXZIBt7=*z43sa|;#H@&dS&RLyV^a$S?^oR_QR)Jb6kA$6u^j$Zx=EB- z#eVJLEyX#eWq>Sxe8-==_*;h8K!?a8iRZ$dIjN+7_G-W7DU06k_n|dgRwE;!ilGr*o;k*l1gnA*DfL=H2 zb6ssuDI_)1DKe5pGo059r{LI30eXgj~ z=@dd61nt3jJuJtdH-JE`kSGSo16LzpCBVqys)9%oX1mA@A?C2LT_mFpF@)L=Q=IcC zjK;9aP&N25WRu1{4s9DMngq8U8aZfWH0#5-M-gZY0d+zqj#Z+F=(mnl&CoEM_d>7( zrH_HnJ2tuvIjXb+;ih1gwKzyWq&OtbZX4Dq>Vwu^+76u*(uwJ{@F8Dr+#oC_5MDod zAzkE{`=H7>)>93-6oG5*5F3vmCw5;;75|)Xwxj|T38M;=bR3Vx1-eWh(zBSRa<*~U zKz>^G+;gr|l0w=PL~yE4K#_+4j8-_v!37tKUB?haGa}AFu?u_dVV;^0B=tx-!N3hOdtkJ6h6@;ZG@cjyOH)JH`wB#%P^_ioKF2b4gW zQks)8mqRR@_-|~XiBem?BlOl(?-79qAdvo7Sz;^l{gXWD{%Bj9td zBj9qrWmPBE+&T`u5?S(`jNn-^Lc@Sv7gjb@u$RU#y4;jJuJV*{s+)F-oLK{6`nNz9 zd*3P9l^nW+sQG4j_tR0%)BXMD$RFEF7a0Xn540{Xm;KG10$y`VEXlAr}0 z8PHiBS$@6bsdL!hU0;Dbn~b#R5w=oneKW}B64Pr^hD3YTh~`^T!< zs}8w;_nh_It)8r2l_h!2TkE~x3;OQ;|BGnly7TAyq7~N3@W5~tPvH)25Dp`qvveL` z%MR5io0X10n+Rt`O*onoYQ2zMqD2H6YrGb-#jFIH8bhKnD||bJug0gwqOp#ws27&T zc99jG4UqS`J5^sDt0@hEHYws(0*x6Fj+z>k2A{X+mg|L@6@1d!;0_C~Y-v!V86nJd zqB9b16J~MMmusj7OYBGemEPY?-I1Q)I>g71xgH|4+qkG^^T{QrC3TW5Uf zLv8PU8ybKbd;k}Ea_3%3Us#&|4$z{2d+UXB-F+|LeX$m(`kq>^Pc9Z$Ub*%8r+{~r zd)Hp_c+(PM919X>iV67zyAU}N%{ya zEKkOZ!5hc-NYFxs&BfySiZ-noJe21XOU1?MrW?sVAhr+ak(K3(tFs3a9|Kk$pTD`h z+wLECGGG>Mw|~BU`PW`;yws-6+9m?%z*pGE?^=g;R@>4JbdMg@Z@RWz+ist`6;-H3 z7l!c~R-vN~VO|Czh++U1;z)26#vqbNaqVJ}4mA$=WN10cB=_6M zY|w6)fmTz)K7|IlD)1=EgE+4`s>Dc}Ks}Y@M95Ac$T}DdHA^bVd6c9ER~8z1ZZMkx z-Xpa3v7#o!=%z)A7M8=F=RM3o4W9K;C&E?KX7q>Ky$(^H&WEVj!_pf(mnR`T${hR9 z%5Y86ij@TU7_}rM$?hoECNi`d!!g>JOvg~QXlB0BxEvXyOfZWkYTFWXhNhBZ8y;ns zLUb)167mpp689}Q@?gKxl4@4&8S0$jWL&@+UyUxO$053@^j9V%hG%J=qP1I)5*~p7(B(iELz0y99I%znDjx~FW?hg!*$%m zx46f8HPc>ZWSCBe=tX!XpeSo#IzBAeax5_l#ebWjnnap zQ^~h7kdH85(JsFMGO%8z8?vb&oec3<@-3VCxt7LAZ~Jyv}1D8^86J?s?mLqx7GlzZm0-bC6PO^)RE$ zG6yN#D9f8>ZaH==!WW}Z>Z+b4y{B~YNbwl0Gea>eo=nkdAZr@=(QrDE8;Oh+c>Z4L zjJL+-haGTTl{ + + + + + diff --git a/lib/widgets/icons.dart b/lib/widgets/icons.dart index 1b5c424b0b..1b8457a9f7 100644 --- a/lib/widgets/icons.dart +++ b/lib/widgets/icons.dart @@ -141,41 +141,44 @@ abstract final class ZulipIcons { /// The Zulip custom icon "search". static const IconData search = IconData(0xf127, fontFamily: "Zulip Icons"); + /// The Zulip custom icon "see_who_reacted". + static const IconData see_who_reacted = IconData(0xf128, fontFamily: "Zulip Icons"); + /// The Zulip custom icon "send". - static const IconData send = IconData(0xf128, fontFamily: "Zulip Icons"); + static const IconData send = IconData(0xf129, fontFamily: "Zulip Icons"); /// The Zulip custom icon "settings". - static const IconData settings = IconData(0xf129, fontFamily: "Zulip Icons"); + static const IconData settings = IconData(0xf12a, fontFamily: "Zulip Icons"); /// The Zulip custom icon "share". - static const IconData share = IconData(0xf12a, fontFamily: "Zulip Icons"); + static const IconData share = IconData(0xf12b, fontFamily: "Zulip Icons"); /// The Zulip custom icon "share_ios". - static const IconData share_ios = IconData(0xf12b, fontFamily: "Zulip Icons"); + static const IconData share_ios = IconData(0xf12c, fontFamily: "Zulip Icons"); /// The Zulip custom icon "smile". - static const IconData smile = IconData(0xf12c, fontFamily: "Zulip Icons"); + static const IconData smile = IconData(0xf12d, fontFamily: "Zulip Icons"); /// The Zulip custom icon "star". - static const IconData star = IconData(0xf12d, fontFamily: "Zulip Icons"); + static const IconData star = IconData(0xf12e, fontFamily: "Zulip Icons"); /// The Zulip custom icon "star_filled". - static const IconData star_filled = IconData(0xf12e, fontFamily: "Zulip Icons"); + static const IconData star_filled = IconData(0xf12f, fontFamily: "Zulip Icons"); /// The Zulip custom icon "three_person". - static const IconData three_person = IconData(0xf12f, fontFamily: "Zulip Icons"); + static const IconData three_person = IconData(0xf130, fontFamily: "Zulip Icons"); /// The Zulip custom icon "topic". - static const IconData topic = IconData(0xf130, fontFamily: "Zulip Icons"); + static const IconData topic = IconData(0xf131, fontFamily: "Zulip Icons"); /// The Zulip custom icon "topics". - static const IconData topics = IconData(0xf131, fontFamily: "Zulip Icons"); + static const IconData topics = IconData(0xf132, fontFamily: "Zulip Icons"); /// The Zulip custom icon "two_person". - static const IconData two_person = IconData(0xf132, fontFamily: "Zulip Icons"); + static const IconData two_person = IconData(0xf133, fontFamily: "Zulip Icons"); /// The Zulip custom icon "unmute". - static const IconData unmute = IconData(0xf133, fontFamily: "Zulip Icons"); + static const IconData unmute = IconData(0xf134, fontFamily: "Zulip Icons"); // END GENERATED ICON DATA } From a29eac8741c4575dbc8ef94654323481474d3ac0 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Thu, 24 Jul 2025 14:19:12 -0700 Subject: [PATCH 3/7] action_sheet: Implement BottomSheetHeaderPlainText, following Figma --- lib/widgets/action_sheet.dart | 28 ++++++++++++++++++++++++++++ lib/widgets/theme.dart | 7 +++++++ 2 files changed, 35 insertions(+) diff --git a/lib/widgets/action_sheet.dart b/lib/widgets/action_sheet.dart index deec4b5952..5dfad584c0 100644 --- a/lib/widgets/action_sheet.dart +++ b/lib/widgets/action_sheet.dart @@ -98,6 +98,34 @@ void _showActionSheet( }); } +/// A header for a bottom sheet with a multiline UI string. +/// +/// Assumes 8px padding below the top of the bottom sheet. +/// +/// Figma: +/// https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=3481-26993&m=dev +class BottomSheetHeaderPlainText extends StatelessWidget { + const BottomSheetHeaderPlainText({super.key, required this.text}); + + final String text; + + @override + Widget build(BuildContext context) { + final designVariables = DesignVariables.of(context); + + return Padding( + padding: EdgeInsets.fromLTRB(16, 8, 16, 4), + child: SizedBox( + width: double.infinity, + child: Text( + style: TextStyle( + color: designVariables.labelTime, + fontSize: 17, + height: 22 / 17), + text))); + } +} + /// A button in an action sheet. /// /// When built from server data, the action sheet ignores changes in that data; diff --git a/lib/widgets/theme.dart b/lib/widgets/theme.dart index 8e70f28e3c..e66a9fc535 100644 --- a/lib/widgets/theme.dart +++ b/lib/widgets/theme.dart @@ -171,6 +171,7 @@ class DesignVariables extends ThemeExtension { labelEdited: const HSLColor.fromAHSL(0.35, 0, 0, 0).toColor(), labelMenuButton: const Color(0xff222222), labelSearchPrompt: const Color(0xff000000).withValues(alpha: 0.5), + labelTime: const Color(0x00000000).withValues(alpha: 0.49), listMenuItemBg: const Color(0xffcbcdd6), listMenuItemIcon: const Color(0xff9194a3), listMenuItemText: const Color(0xff2d303c), @@ -260,6 +261,7 @@ class DesignVariables extends ThemeExtension { labelEdited: const HSLColor.fromAHSL(0.35, 0, 0, 1).toColor(), labelMenuButton: const Color(0xffffffff).withValues(alpha: 0.85), labelSearchPrompt: const Color(0xffffffff).withValues(alpha: 0.5), + labelTime: const Color(0xffffffff).withValues(alpha: 0.50), listMenuItemBg: const Color(0xff2d303c), listMenuItemIcon: const Color(0xff767988), listMenuItemText: const Color(0xffcbcdd6), @@ -358,6 +360,7 @@ class DesignVariables extends ThemeExtension { required this.labelEdited, required this.labelMenuButton, required this.labelSearchPrompt, + required this.labelTime, required this.listMenuItemBg, required this.listMenuItemIcon, required this.listMenuItemText, @@ -447,6 +450,7 @@ class DesignVariables extends ThemeExtension { final Color labelEdited; final Color labelMenuButton; final Color labelSearchPrompt; + final Color labelTime; final Color listMenuItemBg; final Color listMenuItemIcon; final Color listMenuItemText; @@ -531,6 +535,7 @@ class DesignVariables extends ThemeExtension { Color? labelEdited, Color? labelMenuButton, Color? labelSearchPrompt, + Color? labelTime, Color? listMenuItemBg, Color? listMenuItemIcon, Color? listMenuItemText, @@ -610,6 +615,7 @@ class DesignVariables extends ThemeExtension { labelEdited: labelEdited ?? this.labelEdited, labelMenuButton: labelMenuButton ?? this.labelMenuButton, labelSearchPrompt: labelSearchPrompt ?? this.labelSearchPrompt, + labelTime: labelTime ?? this.labelTime, listMenuItemBg: listMenuItemBg ?? this.listMenuItemBg, listMenuItemIcon: listMenuItemIcon ?? this.listMenuItemIcon, listMenuItemText: listMenuItemText ?? this.listMenuItemText, @@ -696,6 +702,7 @@ class DesignVariables extends ThemeExtension { labelEdited: Color.lerp(labelEdited, other.labelEdited, t)!, labelMenuButton: Color.lerp(labelMenuButton, other.labelMenuButton, t)!, labelSearchPrompt: Color.lerp(labelSearchPrompt, other.labelSearchPrompt, t)!, + labelTime: Color.lerp(labelTime, other.labelTime, t)!, listMenuItemBg: Color.lerp(listMenuItemBg, other.listMenuItemBg, t)!, listMenuItemIcon: Color.lerp(listMenuItemIcon, other.listMenuItemIcon, t)!, listMenuItemText: Color.lerp(listMenuItemText, other.listMenuItemText, t)!, From bcb5fc5417f6a0bb25c706544f2e13f1f73a68b1 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Thu, 24 Jul 2025 15:11:41 -0700 Subject: [PATCH 4/7] inset_shadow test [nfc]: Pull PaintPatternPredicate helper outside its test So we can add another test that uses it. --- test/widgets/inset_shadow_test.dart | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/test/widgets/inset_shadow_test.dart b/test/widgets/inset_shadow_test.dart index a8e3d5f498..ca0544b0bb 100644 --- a/test/widgets/inset_shadow_test.dart +++ b/test/widgets/inset_shadow_test.dart @@ -29,20 +29,20 @@ void main() { check(childRect).equals(parentRect); }); - testWidgets('render shadow correctly', (tester) async { - PaintPatternPredicate paintGradient({required Rect rect}) { - // This is inspired by - // https://github.com/flutter/flutter/blob/7b5462cc34af903e2f2de4be7540ff858685cdfc/packages/flutter/test/cupertino/route_test.dart#L1449-L1475 - return (Symbol methodName, List arguments) { - check(methodName).equals(#drawRect); - check(arguments[0]).isA().equals(rect); - // We can't further check [ui.Gradient] because it is opaque: - // https://github.com/flutter/engine/blob/07d01ad1199522fa5889a10c1688c4e1812b6625/lib/ui/painting.dart#L4487 - check(arguments[1]).isA().shader.isA(); - return true; - }; - } + PaintPatternPredicate paintGradient({required Rect rect}) { + // This is inspired by + // https://github.com/flutter/flutter/blob/7b5462cc34af903e2f2de4be7540ff858685cdfc/packages/flutter/test/cupertino/route_test.dart#L1449-L1475 + return (Symbol methodName, List arguments) { + check(methodName).equals(#drawRect); + check(arguments[0]).isA().equals(rect); + // We can't further check [ui.Gradient] because it is opaque: + // https://github.com/flutter/engine/blob/07d01ad1199522fa5889a10c1688c4e1812b6625/lib/ui/painting.dart#L4487 + check(arguments[1]).isA().shader.isA(); + return true; + }; + } + testWidgets('render shadow correctly', (tester) async { await tester.pumpWidget(const Directionality( textDirection: TextDirection.ltr, child: Center( From 86710bae63a5d2cf9e2e6d192c87df64dfcd37f2 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Thu, 24 Jul 2025 15:23:12 -0700 Subject: [PATCH 5/7] inset_shadow: Implement start/end shadows, not just top/bottom --- lib/widgets/inset_shadow.dart | 22 ++++++++++++++++--- test/widgets/inset_shadow_test.dart | 33 +++++++++++++++++++++++++++-- 2 files changed, 50 insertions(+), 5 deletions(-) diff --git a/lib/widgets/inset_shadow.dart b/lib/widgets/inset_shadow.dart index a4133ac7de..3c2533f1d8 100644 --- a/lib/widgets/inset_shadow.dart +++ b/lib/widgets/inset_shadow.dart @@ -17,6 +17,8 @@ class InsetShadowBox extends StatelessWidget { super.key, this.top = 0, this.bottom = 0, + this.start = 0, + this.end = 0, required this.color, required this.child, }); @@ -31,7 +33,17 @@ class InsetShadowBox extends StatelessWidget { /// This does not pad the child widget. final double bottom; - /// The shadow color to fade into transparency from the top and bottom borders. + /// The distance that the shadow from the child's start edge grows endwards. + /// + /// This does not pad the child widget. + final double start; + + /// The distance that the shadow from the child's end edge grows startwards. + /// + /// This does not pad the child widget. + final double end; + + /// The shadow color to fade into transparency from the edges, inward. final Color color; final Widget child; @@ -50,10 +62,14 @@ class InsetShadowBox extends StatelessWidget { fit: StackFit.passthrough, children: [ child, - Positioned(top: 0, height: top, left: 0, right: 0, + if (top != 0) Positioned(top: 0, height: top, left: 0, right: 0, child: DecoratedBox(decoration: _shadowFrom(Alignment.topCenter))), - Positioned(bottom: 0, height: bottom, left: 0, right: 0, + if (bottom != 0) Positioned(bottom: 0, height: bottom, left: 0, right: 0, child: DecoratedBox(decoration: _shadowFrom(Alignment.bottomCenter))), + if (start != 0) PositionedDirectional(start: 0, width: start, top: 0, bottom: 0, + child: DecoratedBox(decoration: _shadowFrom(AlignmentDirectional.centerStart))), + if (end != 0) PositionedDirectional(end: 0, width: end, top: 0, bottom: 0, + child: DecoratedBox(decoration: _shadowFrom(AlignmentDirectional.centerEnd))), ]); } } diff --git a/test/widgets/inset_shadow_test.dart b/test/widgets/inset_shadow_test.dart index ca0544b0bb..6051be47d2 100644 --- a/test/widgets/inset_shadow_test.dart +++ b/test/widgets/inset_shadow_test.dart @@ -16,7 +16,7 @@ void main() { // to ease the check on [Rect] later. alignment: Alignment.topLeft, child: SizedBox(width: 20, height: 20, - child: InsetShadowBox(top: 7, bottom: 3, + child: InsetShadowBox(top: 7, bottom: 3, start: 5, end: 6, color: Colors.red, child: SizedBox.shrink()))))); @@ -42,7 +42,7 @@ void main() { }; } - testWidgets('render shadow correctly', (tester) async { + testWidgets('render shadow correctly: top/bottom', (tester) async { await tester.pumpWidget(const Directionality( textDirection: TextDirection.ltr, child: Center( @@ -61,4 +61,33 @@ void main() { ..something(paintGradient(rect: const Rect.fromLTRB(0, 100-7, 100, 100))) ) as Matcher); }); + + final textDirectionVariant = + ValueVariant({TextDirection.ltr, TextDirection.rtl}); + + testWidgets('render shadow correctly: start/end', (tester) async { + final textDirection = textDirectionVariant.currentValue!; + await tester.pumpWidget(Directionality( + textDirection: textDirection, + child: Center( + // This would be forced to fill up the screen + // if not wrapped in a widget like [Center]. + child: SizedBox(width: 100, height: 100, + child: InsetShadowBox(start: 3, end: 7, + color: Colors.red, + child: SizedBox(width: 30, height: 30)))))); + + final box = tester.renderObject(find.byType(InsetShadowBox)); + check(box).legacyMatcher( + // The coordinate system of these [Rect]'s is relative to the parent + // of the [Gradient] from [InsetShadowBox], not the entire [FlutterView]. + switch (textDirection) { + TextDirection.ltr => paints + ..something(paintGradient(rect: Rect.fromLTRB(0, 0, 0+3, 100))) + ..something(paintGradient(rect: Rect.fromLTRB(100-7, 0, 100, 100))), + TextDirection.rtl => paints + ..something(paintGradient(rect: Rect.fromLTRB(100-3, 0, 100, 100))) + ..something(paintGradient(rect: Rect.fromLTRB(0, 0, 0+7, 100))), + } as Matcher); + }, variant: textDirectionVariant); } From e1f4bddc095b54923f29cbf0ff6914c6cdded63b Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Thu, 24 Jul 2025 17:21:43 -0700 Subject: [PATCH 6/7] emoji_reaction test [nfc]: Pull out some data for other tests to use --- test/widgets/emoji_reaction_test.dart | 28 +++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/test/widgets/emoji_reaction_test.dart b/test/widgets/emoji_reaction_test.dart index 5948e6828c..06a069e850 100644 --- a/test/widgets/emoji_reaction_test.dart +++ b/test/widgets/emoji_reaction_test.dart @@ -54,6 +54,20 @@ void main() { await fontLoader.load(); } + // Base JSON for various unicode emoji reactions. Just missing user_id. + final u1 = {'emoji_name': '+1', 'emoji_code': '1f44d', 'reaction_type': 'unicode_emoji'}; + final u2 = {'emoji_name': 'family_man_man_girl_boy', 'emoji_code': '1f468-200d-1f468-200d-1f467-200d-1f466', 'reaction_type': 'unicode_emoji'}; + final u3 = {'emoji_name': 'slight_smile', 'emoji_code': '1f642', 'reaction_type': 'unicode_emoji'}; + final u4 = {'emoji_name': 'tada', 'emoji_code': '1f389', 'reaction_type': 'unicode_emoji'}; + final u5 = {'emoji_name': 'exploding_head', 'emoji_code': '1f92f', 'reaction_type': 'unicode_emoji'}; + + // Base JSON for various realm-emoji reactions. Just missing user_id. + final i1 = {'emoji_name': 'twocents', 'emoji_code': '181', 'reaction_type': 'realm_emoji'}; + final i2 = {'emoji_name': 'threecents', 'emoji_code': '182', 'reaction_type': 'realm_emoji'}; + + // Base JSON for the one "Zulip extra emoji" reaction. Just missing user_id. + final z1 = {'emoji_name': 'zulip', 'emoji_code': 'zulip', 'reaction_type': 'zulip_extra_emoji'}; + Future setupChipsInBox(WidgetTester tester, { required List reactions, double width = 245.0, // (seen in context on an iPhone 13 Pro) @@ -159,20 +173,6 @@ void main() { skip: io.Platform.isMacOS); } - // Base JSON for various unicode emoji reactions. Just missing user_id. - final u1 = {'emoji_name': '+1', 'emoji_code': '1f44d', 'reaction_type': 'unicode_emoji'}; - final u2 = {'emoji_name': 'family_man_man_girl_boy', 'emoji_code': '1f468-200d-1f468-200d-1f467-200d-1f466', 'reaction_type': 'unicode_emoji'}; - final u3 = {'emoji_name': 'slight_smile', 'emoji_code': '1f642', 'reaction_type': 'unicode_emoji'}; - final u4 = {'emoji_name': 'tada', 'emoji_code': '1f389', 'reaction_type': 'unicode_emoji'}; - final u5 = {'emoji_name': 'exploding_head', 'emoji_code': '1f92f', 'reaction_type': 'unicode_emoji'}; - - // Base JSON for various realm-emoji reactions. Just missing user_id. - final i1 = {'emoji_name': 'twocents', 'emoji_code': '181', 'reaction_type': 'realm_emoji'}; - final i2 = {'emoji_name': 'threecents', 'emoji_code': '182', 'reaction_type': 'realm_emoji'}; - - // Base JSON for the one "Zulip extra emoji" reaction. Just missing user_id. - final z1 = {'emoji_name': 'zulip', 'emoji_code': 'zulip', 'reaction_type': 'zulip_extra_emoji'}; - final user1 = eg.user(fullName: 'abc'); final user2 = eg.user(fullName: 'Long Name With Many Words In It'); final user3 = eg.user(fullName: 'longnamelongnamelongnamelongname'); From e65314433cc8cd2109af07adb304fd671d2833c2 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Wed, 9 Jul 2025 13:54:32 -0700 Subject: [PATCH 7/7] msglist: Support viewing who reacted to a message I experimented with using Semantics to help write human-centered tests, and I ended up adding some configuration that actually seemed to make a reasonable experience in the UI, at least in my testing with VoiceOver. Fixes #740. --- assets/l10n/app_en.arb | 31 ++ lib/generated/l10n/zulip_localizations.dart | 30 ++ .../l10n/zulip_localizations_ar.dart | 27 ++ .../l10n/zulip_localizations_de.dart | 27 ++ .../l10n/zulip_localizations_en.dart | 27 ++ .../l10n/zulip_localizations_fr.dart | 27 ++ .../l10n/zulip_localizations_it.dart | 27 ++ .../l10n/zulip_localizations_ja.dart | 27 ++ .../l10n/zulip_localizations_nb.dart | 27 ++ .../l10n/zulip_localizations_pl.dart | 27 ++ .../l10n/zulip_localizations_ru.dart | 27 ++ .../l10n/zulip_localizations_sk.dart | 27 ++ .../l10n/zulip_localizations_sl.dart | 27 ++ .../l10n/zulip_localizations_uk.dart | 27 ++ .../l10n/zulip_localizations_zh.dart | 27 ++ lib/widgets/action_sheet.dart | 20 + lib/widgets/emoji_reaction.dart | 421 ++++++++++++++++++ test/widgets/action_sheet_test.dart | 49 +- test/widgets/emoji_reaction_test.dart | 178 ++++++++ 19 files changed, 1079 insertions(+), 1 deletion(-) diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index c24f23dce9..14c8a14cd9 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -136,6 +136,37 @@ "@errorUnresolveTopicFailedTitle": { "description": "Error title when marking a topic as unresolved failed." }, + "actionSheetOptionSeeWhoReacted": "See who reacted", + "@actionSheetOptionSeeWhoReacted": { + "description": "Label for the 'See who reacted' button in the message action sheet." + }, + "seeWhoReactedSheetNoReactions": "This message has no reactions.", + "@seeWhoReactedSheetNoReactions": { + "description": "Explanation on the 'See who reacted' sheet when the message has no reactions (because they were removed after the sheet was opened)." + }, + "seeWhoReactedSheetHeaderLabel": "Emoji reactions ({num})", + "@seeWhoReactedSheetHeaderLabel": { + "description": "In the 'See who reacted' sheet, a label for the list of emoji reactions at the top, with the total number of reactions. (An accessibility label for assistive technology.)", + "placeholders": { + "num": {"type": "int", "example": "2"} + } + }, + "seeWhoReactedSheetEmojiNameWithVoteCount": "{emojiName}: {num, plural, =1{1 vote} other{{num} votes}}", + "@seeWhoReactedSheetEmojiNameWithVoteCount": { + "description": "In the 'See who reacted' sheet, an emoji reaction's name and how many votes it has. (An accessibility label for assistive technology.)", + "placeholders": { + "emojiName": {"type": "String", "example": "working_on_it"}, + "num": {"type": "int", "example": "2"} + } + }, + "seeWhoReactedSheetUserListLabel": "Votes for {emojiName} ({num})", + "@seeWhoReactedSheetUserListLabel": { + "description": "In the 'See who reacted' sheet, a label for the list of users who chose an emoji reaction, with the emoji's name and how many votes it has. (An accessibility label for assistive technology.)", + "placeholders": { + "emojiName": {"type": "String", "example": "working_on_it"}, + "num": {"type": "int", "example": "2"} + } + }, "actionSheetOptionCopyMessageText": "Copy message text", "@actionSheetOptionCopyMessageText": { "description": "Label for copy message text button on action sheet." diff --git a/lib/generated/l10n/zulip_localizations.dart b/lib/generated/l10n/zulip_localizations.dart index 9668b50f26..84a7437c53 100644 --- a/lib/generated/l10n/zulip_localizations.dart +++ b/lib/generated/l10n/zulip_localizations.dart @@ -335,6 +335,36 @@ abstract class ZulipLocalizations { /// **'Failed to mark topic as unresolved'** String get errorUnresolveTopicFailedTitle; + /// Label for the 'See who reacted' button in the message action sheet. + /// + /// In en, this message translates to: + /// **'See who reacted'** + String get actionSheetOptionSeeWhoReacted; + + /// Explanation on the 'See who reacted' sheet when the message has no reactions (because they were removed after the sheet was opened). + /// + /// In en, this message translates to: + /// **'This message has no reactions.'** + String get seeWhoReactedSheetNoReactions; + + /// In the 'See who reacted' sheet, a label for the list of emoji reactions at the top, with the total number of reactions. (An accessibility label for assistive technology.) + /// + /// In en, this message translates to: + /// **'Emoji reactions ({num})'** + String seeWhoReactedSheetHeaderLabel(int num); + + /// In the 'See who reacted' sheet, an emoji reaction's name and how many votes it has. (An accessibility label for assistive technology.) + /// + /// In en, this message translates to: + /// **'{emojiName}: {num, plural, =1{1 vote} other{{num} votes}}'** + String seeWhoReactedSheetEmojiNameWithVoteCount(String emojiName, int num); + + /// In the 'See who reacted' sheet, a label for the list of users who chose an emoji reaction, with the emoji's name and how many votes it has. (An accessibility label for assistive technology.) + /// + /// In en, this message translates to: + /// **'Votes for {emojiName} ({num})'** + String seeWhoReactedSheetUserListLabel(String emojiName, int num); + /// Label for copy message text button on action sheet. /// /// In en, this message translates to: diff --git a/lib/generated/l10n/zulip_localizations_ar.dart b/lib/generated/l10n/zulip_localizations_ar.dart index 110b0dbe24..c8b61e3855 100644 --- a/lib/generated/l10n/zulip_localizations_ar.dart +++ b/lib/generated/l10n/zulip_localizations_ar.dart @@ -118,6 +118,33 @@ class ZulipLocalizationsAr extends ZulipLocalizations { String get errorUnresolveTopicFailedTitle => 'Failed to mark topic as unresolved'; + @override + String get actionSheetOptionSeeWhoReacted => 'See who reacted'; + + @override + String get seeWhoReactedSheetNoReactions => 'This message has no reactions.'; + + @override + String seeWhoReactedSheetHeaderLabel(int num) { + return 'Emoji reactions ($num)'; + } + + @override + String seeWhoReactedSheetEmojiNameWithVoteCount(String emojiName, int num) { + String _temp0 = intl.Intl.pluralLogic( + num, + locale: localeName, + other: '$num votes', + one: '1 vote', + ); + return '$emojiName: $_temp0'; + } + + @override + String seeWhoReactedSheetUserListLabel(String emojiName, int num) { + return 'Votes for $emojiName ($num)'; + } + @override String get actionSheetOptionCopyMessageText => 'Copy message text'; diff --git a/lib/generated/l10n/zulip_localizations_de.dart b/lib/generated/l10n/zulip_localizations_de.dart index f3b1bdad67..554539a226 100644 --- a/lib/generated/l10n/zulip_localizations_de.dart +++ b/lib/generated/l10n/zulip_localizations_de.dart @@ -121,6 +121,33 @@ class ZulipLocalizationsDe extends ZulipLocalizations { String get errorUnresolveTopicFailedTitle => 'Thema konnte nicht als ungelöst markiert werden'; + @override + String get actionSheetOptionSeeWhoReacted => 'See who reacted'; + + @override + String get seeWhoReactedSheetNoReactions => 'This message has no reactions.'; + + @override + String seeWhoReactedSheetHeaderLabel(int num) { + return 'Emoji reactions ($num)'; + } + + @override + String seeWhoReactedSheetEmojiNameWithVoteCount(String emojiName, int num) { + String _temp0 = intl.Intl.pluralLogic( + num, + locale: localeName, + other: '$num votes', + one: '1 vote', + ); + return '$emojiName: $_temp0'; + } + + @override + String seeWhoReactedSheetUserListLabel(String emojiName, int num) { + return 'Votes for $emojiName ($num)'; + } + @override String get actionSheetOptionCopyMessageText => 'Nachrichtentext kopieren'; diff --git a/lib/generated/l10n/zulip_localizations_en.dart b/lib/generated/l10n/zulip_localizations_en.dart index f99c386087..5cc435285c 100644 --- a/lib/generated/l10n/zulip_localizations_en.dart +++ b/lib/generated/l10n/zulip_localizations_en.dart @@ -118,6 +118,33 @@ class ZulipLocalizationsEn extends ZulipLocalizations { String get errorUnresolveTopicFailedTitle => 'Failed to mark topic as unresolved'; + @override + String get actionSheetOptionSeeWhoReacted => 'See who reacted'; + + @override + String get seeWhoReactedSheetNoReactions => 'This message has no reactions.'; + + @override + String seeWhoReactedSheetHeaderLabel(int num) { + return 'Emoji reactions ($num)'; + } + + @override + String seeWhoReactedSheetEmojiNameWithVoteCount(String emojiName, int num) { + String _temp0 = intl.Intl.pluralLogic( + num, + locale: localeName, + other: '$num votes', + one: '1 vote', + ); + return '$emojiName: $_temp0'; + } + + @override + String seeWhoReactedSheetUserListLabel(String emojiName, int num) { + return 'Votes for $emojiName ($num)'; + } + @override String get actionSheetOptionCopyMessageText => 'Copy message text'; diff --git a/lib/generated/l10n/zulip_localizations_fr.dart b/lib/generated/l10n/zulip_localizations_fr.dart index cbc18b6d35..515485bbcf 100644 --- a/lib/generated/l10n/zulip_localizations_fr.dart +++ b/lib/generated/l10n/zulip_localizations_fr.dart @@ -118,6 +118,33 @@ class ZulipLocalizationsFr extends ZulipLocalizations { String get errorUnresolveTopicFailedTitle => 'Failed to mark topic as unresolved'; + @override + String get actionSheetOptionSeeWhoReacted => 'See who reacted'; + + @override + String get seeWhoReactedSheetNoReactions => 'This message has no reactions.'; + + @override + String seeWhoReactedSheetHeaderLabel(int num) { + return 'Emoji reactions ($num)'; + } + + @override + String seeWhoReactedSheetEmojiNameWithVoteCount(String emojiName, int num) { + String _temp0 = intl.Intl.pluralLogic( + num, + locale: localeName, + other: '$num votes', + one: '1 vote', + ); + return '$emojiName: $_temp0'; + } + + @override + String seeWhoReactedSheetUserListLabel(String emojiName, int num) { + return 'Votes for $emojiName ($num)'; + } + @override String get actionSheetOptionCopyMessageText => 'Copy message text'; diff --git a/lib/generated/l10n/zulip_localizations_it.dart b/lib/generated/l10n/zulip_localizations_it.dart index 2d7d35e23e..1a96a39912 100644 --- a/lib/generated/l10n/zulip_localizations_it.dart +++ b/lib/generated/l10n/zulip_localizations_it.dart @@ -120,6 +120,33 @@ class ZulipLocalizationsIt extends ZulipLocalizations { String get errorUnresolveTopicFailedTitle => 'Impossibile contrassegnare l\'argomento come irrisolto'; + @override + String get actionSheetOptionSeeWhoReacted => 'See who reacted'; + + @override + String get seeWhoReactedSheetNoReactions => 'This message has no reactions.'; + + @override + String seeWhoReactedSheetHeaderLabel(int num) { + return 'Emoji reactions ($num)'; + } + + @override + String seeWhoReactedSheetEmojiNameWithVoteCount(String emojiName, int num) { + String _temp0 = intl.Intl.pluralLogic( + num, + locale: localeName, + other: '$num votes', + one: '1 vote', + ); + return '$emojiName: $_temp0'; + } + + @override + String seeWhoReactedSheetUserListLabel(String emojiName, int num) { + return 'Votes for $emojiName ($num)'; + } + @override String get actionSheetOptionCopyMessageText => 'Copia il testo del messaggio'; diff --git a/lib/generated/l10n/zulip_localizations_ja.dart b/lib/generated/l10n/zulip_localizations_ja.dart index edf5c759f9..588b42c007 100644 --- a/lib/generated/l10n/zulip_localizations_ja.dart +++ b/lib/generated/l10n/zulip_localizations_ja.dart @@ -116,6 +116,33 @@ class ZulipLocalizationsJa extends ZulipLocalizations { @override String get errorUnresolveTopicFailedTitle => 'トピックを未解決にできませんでした'; + @override + String get actionSheetOptionSeeWhoReacted => 'See who reacted'; + + @override + String get seeWhoReactedSheetNoReactions => 'This message has no reactions.'; + + @override + String seeWhoReactedSheetHeaderLabel(int num) { + return 'Emoji reactions ($num)'; + } + + @override + String seeWhoReactedSheetEmojiNameWithVoteCount(String emojiName, int num) { + String _temp0 = intl.Intl.pluralLogic( + num, + locale: localeName, + other: '$num votes', + one: '1 vote', + ); + return '$emojiName: $_temp0'; + } + + @override + String seeWhoReactedSheetUserListLabel(String emojiName, int num) { + return 'Votes for $emojiName ($num)'; + } + @override String get actionSheetOptionCopyMessageText => 'メッセージ本文をコピー'; diff --git a/lib/generated/l10n/zulip_localizations_nb.dart b/lib/generated/l10n/zulip_localizations_nb.dart index 0568bc0ae7..2bd4bb35f3 100644 --- a/lib/generated/l10n/zulip_localizations_nb.dart +++ b/lib/generated/l10n/zulip_localizations_nb.dart @@ -118,6 +118,33 @@ class ZulipLocalizationsNb extends ZulipLocalizations { String get errorUnresolveTopicFailedTitle => 'Failed to mark topic as unresolved'; + @override + String get actionSheetOptionSeeWhoReacted => 'See who reacted'; + + @override + String get seeWhoReactedSheetNoReactions => 'This message has no reactions.'; + + @override + String seeWhoReactedSheetHeaderLabel(int num) { + return 'Emoji reactions ($num)'; + } + + @override + String seeWhoReactedSheetEmojiNameWithVoteCount(String emojiName, int num) { + String _temp0 = intl.Intl.pluralLogic( + num, + locale: localeName, + other: '$num votes', + one: '1 vote', + ); + return '$emojiName: $_temp0'; + } + + @override + String seeWhoReactedSheetUserListLabel(String emojiName, int num) { + return 'Votes for $emojiName ($num)'; + } + @override String get actionSheetOptionCopyMessageText => 'Copy message text'; diff --git a/lib/generated/l10n/zulip_localizations_pl.dart b/lib/generated/l10n/zulip_localizations_pl.dart index c96ab24679..c4c7a24873 100644 --- a/lib/generated/l10n/zulip_localizations_pl.dart +++ b/lib/generated/l10n/zulip_localizations_pl.dart @@ -121,6 +121,33 @@ class ZulipLocalizationsPl extends ZulipLocalizations { String get errorUnresolveTopicFailedTitle => 'Nie udało się oznaczyć brak rozwiązania'; + @override + String get actionSheetOptionSeeWhoReacted => 'See who reacted'; + + @override + String get seeWhoReactedSheetNoReactions => 'This message has no reactions.'; + + @override + String seeWhoReactedSheetHeaderLabel(int num) { + return 'Emoji reactions ($num)'; + } + + @override + String seeWhoReactedSheetEmojiNameWithVoteCount(String emojiName, int num) { + String _temp0 = intl.Intl.pluralLogic( + num, + locale: localeName, + other: '$num votes', + one: '1 vote', + ); + return '$emojiName: $_temp0'; + } + + @override + String seeWhoReactedSheetUserListLabel(String emojiName, int num) { + return 'Votes for $emojiName ($num)'; + } + @override String get actionSheetOptionCopyMessageText => 'Skopiuj tekst wiadomości'; diff --git a/lib/generated/l10n/zulip_localizations_ru.dart b/lib/generated/l10n/zulip_localizations_ru.dart index be5de60e97..d29418e457 100644 --- a/lib/generated/l10n/zulip_localizations_ru.dart +++ b/lib/generated/l10n/zulip_localizations_ru.dart @@ -121,6 +121,33 @@ class ZulipLocalizationsRu extends ZulipLocalizations { String get errorUnresolveTopicFailedTitle => 'Не удалось отметить тему как нерешенную'; + @override + String get actionSheetOptionSeeWhoReacted => 'See who reacted'; + + @override + String get seeWhoReactedSheetNoReactions => 'This message has no reactions.'; + + @override + String seeWhoReactedSheetHeaderLabel(int num) { + return 'Emoji reactions ($num)'; + } + + @override + String seeWhoReactedSheetEmojiNameWithVoteCount(String emojiName, int num) { + String _temp0 = intl.Intl.pluralLogic( + num, + locale: localeName, + other: '$num votes', + one: '1 vote', + ); + return '$emojiName: $_temp0'; + } + + @override + String seeWhoReactedSheetUserListLabel(String emojiName, int num) { + return 'Votes for $emojiName ($num)'; + } + @override String get actionSheetOptionCopyMessageText => 'Скопировать текст сообщения'; diff --git a/lib/generated/l10n/zulip_localizations_sk.dart b/lib/generated/l10n/zulip_localizations_sk.dart index 33b4465eb6..fec26ca427 100644 --- a/lib/generated/l10n/zulip_localizations_sk.dart +++ b/lib/generated/l10n/zulip_localizations_sk.dart @@ -118,6 +118,33 @@ class ZulipLocalizationsSk extends ZulipLocalizations { String get errorUnresolveTopicFailedTitle => 'Failed to mark topic as unresolved'; + @override + String get actionSheetOptionSeeWhoReacted => 'See who reacted'; + + @override + String get seeWhoReactedSheetNoReactions => 'This message has no reactions.'; + + @override + String seeWhoReactedSheetHeaderLabel(int num) { + return 'Emoji reactions ($num)'; + } + + @override + String seeWhoReactedSheetEmojiNameWithVoteCount(String emojiName, int num) { + String _temp0 = intl.Intl.pluralLogic( + num, + locale: localeName, + other: '$num votes', + one: '1 vote', + ); + return '$emojiName: $_temp0'; + } + + @override + String seeWhoReactedSheetUserListLabel(String emojiName, int num) { + return 'Votes for $emojiName ($num)'; + } + @override String get actionSheetOptionCopyMessageText => 'Skopírovať text správy'; diff --git a/lib/generated/l10n/zulip_localizations_sl.dart b/lib/generated/l10n/zulip_localizations_sl.dart index 8d587b9085..2bc9506880 100644 --- a/lib/generated/l10n/zulip_localizations_sl.dart +++ b/lib/generated/l10n/zulip_localizations_sl.dart @@ -119,6 +119,33 @@ class ZulipLocalizationsSl extends ZulipLocalizations { String get errorUnresolveTopicFailedTitle => 'Neuspela označitev teme kot nerazrešene'; + @override + String get actionSheetOptionSeeWhoReacted => 'See who reacted'; + + @override + String get seeWhoReactedSheetNoReactions => 'This message has no reactions.'; + + @override + String seeWhoReactedSheetHeaderLabel(int num) { + return 'Emoji reactions ($num)'; + } + + @override + String seeWhoReactedSheetEmojiNameWithVoteCount(String emojiName, int num) { + String _temp0 = intl.Intl.pluralLogic( + num, + locale: localeName, + other: '$num votes', + one: '1 vote', + ); + return '$emojiName: $_temp0'; + } + + @override + String seeWhoReactedSheetUserListLabel(String emojiName, int num) { + return 'Votes for $emojiName ($num)'; + } + @override String get actionSheetOptionCopyMessageText => 'Kopiraj besedilo sporočila'; diff --git a/lib/generated/l10n/zulip_localizations_uk.dart b/lib/generated/l10n/zulip_localizations_uk.dart index 6799942531..5e2798dfe1 100644 --- a/lib/generated/l10n/zulip_localizations_uk.dart +++ b/lib/generated/l10n/zulip_localizations_uk.dart @@ -122,6 +122,33 @@ class ZulipLocalizationsUk extends ZulipLocalizations { String get errorUnresolveTopicFailedTitle => 'Не вдалося позначити тему як невирішену'; + @override + String get actionSheetOptionSeeWhoReacted => 'See who reacted'; + + @override + String get seeWhoReactedSheetNoReactions => 'This message has no reactions.'; + + @override + String seeWhoReactedSheetHeaderLabel(int num) { + return 'Emoji reactions ($num)'; + } + + @override + String seeWhoReactedSheetEmojiNameWithVoteCount(String emojiName, int num) { + String _temp0 = intl.Intl.pluralLogic( + num, + locale: localeName, + other: '$num votes', + one: '1 vote', + ); + return '$emojiName: $_temp0'; + } + + @override + String seeWhoReactedSheetUserListLabel(String emojiName, int num) { + return 'Votes for $emojiName ($num)'; + } + @override String get actionSheetOptionCopyMessageText => 'Копіювати текст повідомлення'; diff --git a/lib/generated/l10n/zulip_localizations_zh.dart b/lib/generated/l10n/zulip_localizations_zh.dart index c44852c041..5db572f573 100644 --- a/lib/generated/l10n/zulip_localizations_zh.dart +++ b/lib/generated/l10n/zulip_localizations_zh.dart @@ -118,6 +118,33 @@ class ZulipLocalizationsZh extends ZulipLocalizations { String get errorUnresolveTopicFailedTitle => 'Failed to mark topic as unresolved'; + @override + String get actionSheetOptionSeeWhoReacted => 'See who reacted'; + + @override + String get seeWhoReactedSheetNoReactions => 'This message has no reactions.'; + + @override + String seeWhoReactedSheetHeaderLabel(int num) { + return 'Emoji reactions ($num)'; + } + + @override + String seeWhoReactedSheetEmojiNameWithVoteCount(String emojiName, int num) { + String _temp0 = intl.Intl.pluralLogic( + num, + locale: localeName, + other: '$num votes', + one: '1 vote', + ); + return '$emojiName: $_temp0'; + } + + @override + String seeWhoReactedSheetUserListLabel(String emojiName, int num) { + return 'Votes for $emojiName ($num)'; + } + @override String get actionSheetOptionCopyMessageText => 'Copy message text'; diff --git a/lib/widgets/action_sheet.dart b/lib/widgets/action_sheet.dart index 5dfad584c0..2f577f39fe 100644 --- a/lib/widgets/action_sheet.dart +++ b/lib/widgets/action_sheet.dart @@ -642,6 +642,9 @@ void showMessageActionSheet({required BuildContext context, required Message mes final popularEmojiLoaded = store.popularEmojiCandidates().isNotEmpty; + final reactions = message.reactions; + final hasReactions = reactions != null && reactions.total > 0; + // The UI that's conditioned on this won't live-update during this appearance // of the action sheet (we avoid calling composeBoxControllerOf in a build // method; see its doc). @@ -659,6 +662,8 @@ void showMessageActionSheet({required BuildContext context, required Message mes final optionButtons = [ if (popularEmojiLoaded) ReactionButtons(message: message, pageContext: pageContext), + if (hasReactions) + ViewReactionsButton(message: message, pageContext: pageContext), StarButton(message: message, pageContext: pageContext), if (isComposeBoxOffered) QuoteAndReplyButton(message: message, pageContext: pageContext), @@ -885,6 +890,21 @@ class ReactionButtons extends StatelessWidget { } } +class ViewReactionsButton extends MessageActionSheetMenuItemButton { + ViewReactionsButton({super.key, required super.message, required super.pageContext}); + + @override IconData get icon => ZulipIcons.see_who_reacted; + + @override + String label(ZulipLocalizations zulipLocalizations) { + return zulipLocalizations.actionSheetOptionSeeWhoReacted; + } + + @override void onPressed() { + showViewReactionsSheet(pageContext, messageId: message.id); + } +} + class StarButton extends MessageActionSheetMenuItemButton { StarButton({super.key, required super.message, required super.pageContext}); diff --git a/lib/widgets/emoji_reaction.dart b/lib/widgets/emoji_reaction.dart index bc22b4d4a5..29bee1bf11 100644 --- a/lib/widgets/emoji_reaction.dart +++ b/lib/widgets/emoji_reaction.dart @@ -1,4 +1,6 @@ +import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/semantics.dart'; import '../api/exception.dart'; import '../api/model/model.dart'; @@ -6,13 +8,18 @@ import '../api/route/messages.dart'; import '../generated/l10n/zulip_localizations.dart'; import '../model/autocomplete.dart'; import '../model/emoji.dart'; +import '../model/store.dart'; +import 'action_sheet.dart'; import 'color.dart'; import 'dialog.dart'; import 'emoji.dart'; import 'inset_shadow.dart'; +import 'page.dart'; +import 'profile.dart'; import 'store.dart'; import 'text.dart'; import 'theme.dart'; +import 'user.dart'; /// Emoji-reaction styles that differ between light and dark themes. class EmojiReactionTheme extends ThemeExtension { @@ -205,6 +212,12 @@ class ReactionChip extends StatelessWidget { customBorder: shape, splashColor: splashColor, highlightColor: highlightColor, + onLongPress: () { + showViewReactionsSheet(PageRoot.contextOf(context), + messageId: messageId, + initialReactionType: reactionType, + initialEmojiCode: emojiCode); + }, onTap: () { (selfVoted ? removeReaction : addReaction).call(store.connection, messageId: messageId, @@ -614,3 +627,411 @@ class EmojiPickerListEntry extends StatelessWidget { )); } } + +/// Opens a bottom sheet showing who reacted to the message. +void showViewReactionsSheet(BuildContext pageContext, { + required int messageId, + ReactionType? initialReactionType, + String? initialEmojiCode, +}) { + final accountId = PerAccountStoreWidget.accountIdOf(pageContext); + + showModalBottomSheet( + context: pageContext, + // Clip.hardEdge looks bad; Clip.antiAliasWithSaveLayer looks pixel-perfect + // on my iPhone 13 Pro but is marked as "much slower": + // https://api.flutter.dev/flutter/dart-ui/Clip.html + clipBehavior: Clip.antiAlias, + useSafeArea: true, + isScrollControlled: true, + builder: (_) { + return PerAccountStoreWidget( + accountId: accountId, + child: SafeArea( + minimum: const EdgeInsets.only(bottom: 16), + child: ViewReactions(pageContext, + messageId: messageId, + initialEmojiCode: initialEmojiCode, + initialReactionType: initialReactionType))); + }); +} + +class ViewReactions extends StatefulWidget { + const ViewReactions(this.pageContext, { + super.key, + required this.messageId, + this.initialReactionType, + this.initialEmojiCode, + }); + + final BuildContext pageContext; + final int messageId; + final ReactionType? initialReactionType; + final String? initialEmojiCode; + + @override + State createState() => _ViewReactionsState(); +} + +class _ViewReactionsState extends State with PerAccountStoreAwareStateMixin { + ReactionType? reactionType; + String? emojiCode; + String? emojiName; + + PerAccountStore? store; + + void _setSelection(ReactionWithVotes? selection) { + setState(() { + reactionType = selection?.reactionType; + emojiCode = selection?.emojiCode; + emojiName = selection?.emojiName; + }); + } + + void _storeChanged() { + _reconcile(); + } + + /// Check that the given reaction still has votes; + /// if not, select a different one if possible or clear the selection. + void _reconcile() { + final message = PerAccountStoreWidget.of(context).messages[widget.messageId]; + + final reactions = message?.reactions?.aggregated; + + if (reactions == null || reactions.isEmpty) { + _setSelection(null); + return; + } + + final selectedReaction = reactions.firstWhereOrNull( + (x) => x.reactionType == reactionType && x.emojiCode == emojiCode); + + // TODO scroll into view + _setSelection(selectedReaction + // first item will exist; early-return above on reactions.isEmpty + ?? reactions.first); + } + + @override + void onNewStore() { + // TODO(#1747) listen for changes in the message's reactions + store?.removeListener(_storeChanged); + store = PerAccountStoreWidget.of(context); + store!.addListener(_storeChanged); + if (reactionType == null && widget.initialReactionType != null) { + assert(emojiCode == null); + assert(widget.initialEmojiCode != null); + reactionType = widget.initialReactionType!; + emojiCode = widget.initialEmojiCode!; + } + _reconcile(); + } + + @override + void dispose() { + store?.removeListener(_storeChanged); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + // TODO could pull out this layout/appearance code, + // focusing this widget only on state management + return SizedBox( + width: double.infinity, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ViewReactionsHeader(widget.pageContext, + messageId: widget.messageId, + reactionType: reactionType, + emojiCode: emojiCode, + onRequestSelect: (r) => _setSelection(r), + ), + // TODO if all reactions (or whole message) disappeared, + // we show a message saying there are no reactions, + // but the layout shifts (the sheet's height changes dramatically); + // we should avoid this. + if (reactionType != null && emojiCode != null) Flexible( + child: ViewReactionsUserList(widget.pageContext, + messageId: widget.messageId, + reactionType: reactionType!, + emojiCode: emojiCode!, + emojiName: emojiName!)), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: const BottomSheetDismissButton(style: BottomSheetDismissButtonStyle.close)) + ])); + } +} + +class ViewReactionsHeader extends StatelessWidget { + const ViewReactionsHeader( + this.pageContext, { + super.key, + required this.messageId, + required this.reactionType, + required this.emojiCode, + required this.onRequestSelect, + }); + + final BuildContext pageContext; + final int messageId; + final ReactionType? reactionType; + final String? emojiCode; + final void Function(ReactionWithVotes) onRequestSelect; + + @override + Widget build(BuildContext context) { + final designVariables = DesignVariables.of(context); + final zulipLocalizations = ZulipLocalizations.of(context); + final message = PerAccountStoreWidget.of(context).messages[messageId]; + + final reactions = message?.reactions; + + if (reactions == null || reactions.aggregated.isEmpty) { + return Padding( + padding: const EdgeInsets.only(top: 8), + child: BottomSheetHeaderPlainText(text: zulipLocalizations.seeWhoReactedSheetNoReactions), + ); + } + + return Padding( + padding: const EdgeInsets.only(top: 16, bottom: 4), + child: InsetShadowBox(start: 8, end: 8, + color: designVariables.bgContextMenu, + child: SingleChildScrollView( + // TODO(upstream) we want to pass excludeFromSemantics: true + // to the underlying Scrollable to remove an unwanted node + // in accessibility focus traversal. + scrollDirection: Axis.horizontal, + physics: ClampingScrollPhysics(), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Semantics( + role: SemanticsRole.tabBar, + container: true, + explicitChildNodes: true, + label: zulipLocalizations.seeWhoReactedSheetHeaderLabel(reactions.total), + child: Row( + children: reactions.aggregated.map((r) => + _ViewReactionsEmojiItem( + reactionWithVotes: r, + selected: r.reactionType == reactionType && r.emojiCode == emojiCode, + onRequestSelect: () => onRequestSelect(r)), + ).toList())))))); + } +} + +class _ViewReactionsEmojiItem extends StatelessWidget { + const _ViewReactionsEmojiItem({ + required this.reactionWithVotes, + required this.selected, + required this.onRequestSelect, + }); + + final ReactionWithVotes reactionWithVotes; + final bool selected; + final void Function() onRequestSelect; + + static const double emojiSize = 24; + + void _scrollIntoView(BuildContext context) { + Scrollable.ensureVisible(context, + alignment: 0.5, duration: Duration(milliseconds: 200)); + } + + void _handleTap(BuildContext context) { + _scrollIntoView(context); + onRequestSelect(); + } + + @override + Widget build(BuildContext context) { + final zulipLocalizations = ZulipLocalizations.of(context); + final designVariables = DesignVariables.of(context); + final store = PerAccountStoreWidget.of(context); + final count = reactionWithVotes.userIds.length; + + final emojiName = reactionWithVotes.emojiName; + final emojiDisplay = store.emojiDisplayFor( + emojiType: reactionWithVotes.reactionType, + emojiCode: reactionWithVotes.emojiCode, + emojiName: emojiName); + + // Don't use a :text_emoji:-style display here. + final placeholder = SizedBox.fromSize(size: Size.square(emojiSize)); + + // TODO make a helper widget for this + final emoji = switch (emojiDisplay) { + UnicodeEmojiDisplay() => UnicodeEmojiWidget( + size: emojiSize, + emojiDisplay: emojiDisplay), + ImageEmojiDisplay() => ImageEmojiWidget( + size: emojiSize, + emojiDisplay: emojiDisplay, + // If image emoji fails to load, show nothing. + errorBuilder: (_, _, _) => placeholder), + TextEmojiDisplay() => placeholder, + }; + + Widget result = Tooltip( + message: emojiName, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => _handleTap(context), + child: Container( + decoration: BoxDecoration( + border: Border.all( + // This border seems to affect the layout + // (I thought it was normally paint-only?), + // so always include a border, so positions don't shift. + color: selected + ? designVariables.borderBar + : Colors.transparent), + borderRadius: BorderRadius.circular(10), + color: selected ? designVariables.background : null, + ), + padding: EdgeInsets.fromLTRB(14, 4.5, 14, 4.5), + child: Center( + child: Column( + spacing: 3, + mainAxisSize: MainAxisSize.min, + children: [ + emoji, + Text( + style: TextStyle( + color: designVariables.title, + fontSize: 14, + height: 14 / 14), + count.toString()), // TODO(i18n) number formatting? + ]))))); + + return Semantics( + role: SemanticsRole.tab, + onDidGainAccessibilityFocus: () => _scrollIntoView(context), + + // I *think* we're following the doc with this but it's hard to tell; + // I've only tested on iOS and I didn't notice a behavior change. + controlsNodes: {ViewReactionsUserList.semanticsIdentifier}, + + selected: selected, + label: zulipLocalizations.seeWhoReactedSheetEmojiNameWithVoteCount(emojiName, count), + onTap: onRequestSelect, + child: ExcludeSemantics( + child: result)); + } +} + + +@visibleForTesting +class ViewReactionsUserList extends StatelessWidget { + const ViewReactionsUserList(this.pageContext, { + super.key, + required this.messageId, + required this.reactionType, + required this.emojiCode, + required this.emojiName, + }); + + final BuildContext pageContext; + final int messageId; + final ReactionType reactionType; + final String emojiCode; + final String emojiName; + + static const semanticsIdentifier = 'view-reactions-user-list'; + + @override + Widget build(BuildContext context) { + final zulipLocalizations = ZulipLocalizations.of(context); + final store = PerAccountStoreWidget.of(context); + final designVariables = DesignVariables.of(context); + + final message = store.messages[messageId]; + + final userIds = message?.reactions?.aggregated.firstWhereOrNull( + (x) => x.reactionType == reactionType && x.emojiCode == emojiCode + )?.userIds.toList(); + + // (No filtering of muted or deactivated users.) + + if (userIds == null) { + // This reaction lost all its votes, or the message was deleted. + return SizedBox.shrink(); + } + + // TODO sort userIds? + + Widget result = InsetShadowBox( + top: 8, bottom: 8, + color: designVariables.bgContextMenu, + child: SizedBox( + height: 400, // TODO(design) tune + child: ListView.builder( + padding: EdgeInsets.symmetric(vertical: 8), + itemCount: userIds.length, + itemBuilder: (context, index) => + ViewReactionsUserItem(context, userId: userIds[index])))); + + return Semantics( + identifier: semanticsIdentifier, // See note on `controlsNodes` on the tab. + label: zulipLocalizations.seeWhoReactedSheetUserListLabel(emojiName, userIds.length), + role: SemanticsRole.tabPanel, + container: true, + child: result); + } +} + +@visibleForTesting +class ViewReactionsUserItem extends StatelessWidget { + const ViewReactionsUserItem(this.pageContext, { + super.key, + required this.userId, + }); + + final BuildContext pageContext; + final int userId; + + void _onPressed() { + // Dismiss the action sheet. + Navigator.pop(pageContext); + + Navigator.push(pageContext, + ProfilePage.buildRoute(context: pageContext, userId: userId)); + } + + @override + Widget build(BuildContext context) { + final store = PerAccountStoreWidget.of(context); + final designVariables = DesignVariables.of(context); + + return InkWell( + onTap: _onPressed, + splashFactory: NoSplash.splashFactory, + overlayColor: WidgetStateColor.resolveWith((states) => + states.any((e) => e == WidgetState.pressed) + ? designVariables.contextMenuItemBg.withFadedAlpha(0.20) + : Colors.transparent), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row(spacing: 8, children: [ + Avatar( + size: 32, + borderRadius: 3, + backgroundColor: designVariables.bgContextMenu, + userId: userId), + Flexible( + child: Text( + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 17, + height: 17 / 17, + color: designVariables.textMessage, + ).merge(weightVariableTextStyle(context, wght: 500)), + store.userDisplayName(userId))), + ]))); + } +} diff --git a/test/widgets/action_sheet_test.dart b/test/widgets/action_sheet_test.dart index 631856efde..6028867980 100644 --- a/test/widgets/action_sheet_test.dart +++ b/test/widgets/action_sheet_test.dart @@ -26,6 +26,7 @@ import 'package:zulip/widgets/app_bar.dart'; import 'package:zulip/widgets/button.dart'; import 'package:zulip/widgets/compose_box.dart'; import 'package:zulip/widgets/content.dart'; +import 'package:zulip/widgets/emoji_reaction.dart'; import 'package:zulip/widgets/home.dart'; import 'package:zulip/widgets/icons.dart'; import 'package:zulip/widgets/inbox.dart'; @@ -50,6 +51,7 @@ import 'test_app.dart'; late PerAccountStore store; late FakeApiConnection connection; +late TransitionDurationObserver transitionDurationObserver; /// Simulates loading a [MessageListPage] and long-pressing on [message]. Future setupToMessageActionSheet(WidgetTester tester, { @@ -98,9 +100,13 @@ Future setupToMessageActionSheet(WidgetTester tester, { : eg.serverEmojiDataPopular); } + transitionDurationObserver = TransitionDurationObserver(); + connection.prepare(json: eg.newestGetMessagesResult( foundOldest: true, messages: [message]).toJson()); - await tester.pumpWidget(TestZulipApp(accountId: selfAccount.id, + await tester.pumpWidget(TestZulipApp( + accountId: selfAccount.id, + navigatorObservers: [transitionDurationObserver], child: MessageListPage(initNarrow: narrow))); // global store, per-account store, and message list get loaded @@ -1033,6 +1039,47 @@ void main() { } }); + group('ViewReactionsButton', () { + final findButtonInSheet = find.descendant( + of: find.byType(BottomSheet), + matching: find.byIcon(ZulipIcons.see_who_reacted)); + + testWidgets('not visible if message has no reactions', (tester) async { + final message = eg.streamMessage(reactions: []); + await setupToMessageActionSheet(tester, + message: message, narrow: CombinedFeedNarrow()); + + check(findButtonInSheet).findsNothing(); + }); + + Future tapButton(WidgetTester tester) async { + await tester.ensureVisible(findButtonInSheet); + await tester.pump(); // [MenuItemButton.onPressed] called in a post-frame callback: flutter/flutter@e4a39fa2e + await tester.tap(findButtonInSheet); + } + + testWidgets('smoke', (tester) async { + final message = eg.streamMessage(reactions: [eg.unicodeEmojiReaction]); + await setupToMessageActionSheet(tester, + message: message, narrow: CombinedFeedNarrow()); + + await tapButton(tester); + + // The message action sheet exits and the view-reactions sheet enters. + // + // This just pumps through twice the duration of the latest transition. + // Ideally we'd check that the two expected transitions were triggered + // and that they started at the same time, and pump through the + // longer of the two durations. + // TODO(upstream) support this in TransitionDurationObserver + await transitionDurationObserver.pumpPastTransition(tester); + await transitionDurationObserver.pumpPastTransition(tester); + + check(findButtonInSheet).findsNothing(); // the message action sheet exited + check(find.byType(ViewReactions)).findsOne(); + }); + }); + group('StarButton', () { Future tapButton(WidgetTester tester, {bool starred = false}) async { // Starred messages include the same icon so we need to diff --git a/test/widgets/emoji_reaction_test.dart b/test/widgets/emoji_reaction_test.dart index 06a069e850..0bc5bc5470 100644 --- a/test/widgets/emoji_reaction_test.dart +++ b/test/widgets/emoji_reaction_test.dart @@ -1,9 +1,11 @@ import 'dart:io' as io; import 'dart:io'; +import 'dart:ui'; import 'package:checks/checks.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/semantics.dart'; import 'package:flutter/services.dart'; import 'package:flutter_checks/flutter_checks.dart'; @@ -40,6 +42,7 @@ void main() { late PerAccountStore store; late FakeApiConnection connection; + late TransitionDurationObserver transitionDurationObserver; Future prepare() async { addTearDown(testBinding.reset); @@ -68,13 +71,18 @@ void main() { // Base JSON for the one "Zulip extra emoji" reaction. Just missing user_id. final z1 = {'emoji_name': 'zulip', 'emoji_code': 'zulip', 'reaction_type': 'zulip_extra_emoji'}; + String nameOf(Map jsonEmoji) => jsonEmoji['emoji_name']!; + Future setupChipsInBox(WidgetTester tester, { required List reactions, double width = 245.0, // (seen in context on an iPhone 13 Pro) }) async { final message = eg.streamMessage(reactions: reactions); + await store.addMessage(message); + transitionDurationObserver = TransitionDurationObserver(); await tester.pumpWidget(TestZulipApp(accountId: eg.selfAccount.id, + navigatorObservers: [transitionDurationObserver], child: Center( child: ColoredBox( color: Colors.white, @@ -90,6 +98,44 @@ void main() { check(reactionChipsList).size.isNotNull().width.equals(width); } + final findViewReactionsTabBar = find.semantics.byPredicate((node) => + node.role == SemanticsRole.tabBar + && node.label.contains('Emoji reactions')); + + FinderBase findViewReactionsEmojiItem(String emojiName) => + find.semantics.descendant( + of: findViewReactionsTabBar, + matching: find.semantics.byPredicate( + (node) => node.role == SemanticsRole.tab && node.label.contains(emojiName))); + + /// Checks that a given emoji item is present or absent in [ViewReactions]. + /// + /// If the `expectFoo` fields are null, checks that the item is absent, + /// otherwise checks that it is present with the given details. + void checkViewReactionsEmojiItem(WidgetTester tester, { + required String emojiName, + required int? expectCount, + required bool? expectSelected, + }) { + assert((expectCount == null) == (expectSelected == null)); + check(findViewReactionsTabBar).findsOne(); + + final nodes = findViewReactionsEmojiItem(emojiName).evaluate(); + check(nodes).length.isLessThan(2); + + if (expectCount == null) { + check(nodes).isEmpty(); + } else { + final expectedLabel = switch (expectCount) { + 1 => '$emojiName: 1 vote', + _ => '$emojiName: $expectCount votes', + }; + check(nodes).single.containsSemantics( + label: expectedLabel, + isSelected: expectSelected!); + } + } + group('ReactionChipsList', () { // Smoke tests under various conditions. for (final displayEmojiReactionUsers in [true, false]) { @@ -249,6 +295,24 @@ void main() { matching: find.text('Muted user, User 2') )).findsOne(); }); + + testWidgets('show view-reactions sheet on long-press', (tester) async { + await prepare(); + await store.addUser(eg.otherUser); + + await setupChipsInBox(tester, + reactions: [ + Reaction.fromJson({'user_id': eg.selfUser.userId, ...u1}), + Reaction.fromJson({'user_id': eg.otherUser.userId, ...u2}), + ]); + + await tester.longPress(find.byType(ReactionChip).last); + await tester.pump(); + await transitionDurationObserver.pumpPastTransition(tester); + + checkViewReactionsEmojiItem(tester, + emojiName: nameOf(u2), expectCount: 1, expectSelected: true); + }); }); testWidgets('Smoke test for light/dark/lerped', (tester) async { @@ -581,4 +645,118 @@ void main() { }); }); }); + + group('showViewReactionsSheet', () { + Future setupViewReactionsSheet(WidgetTester tester, { + required StreamMessage message, + List usersExcludingSelf = const [], + }) async { + assert(message.reactions != null && message.reactions!.total > 0); + addTearDown(testBinding.reset); + + final httpClient = FakeImageHttpClient(); + debugNetworkImageHttpClientProvider = () => httpClient; + httpClient.request.response + ..statusCode = HttpStatus.ok + ..content = kSolidBlueAvatar; + + await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); + store = await testBinding.globalStore.perAccount(eg.selfAccount.id); + await store.addUsers([ + eg.selfUser, + ...usersExcludingSelf, + ]); + final stream = eg.stream(streamId: message.streamId); + await store.addStream(stream); + await store.addSubscription(eg.subscription(stream)); + + transitionDurationObserver = TransitionDurationObserver(); + + connection = store.connection as FakeApiConnection; + connection.prepare(json: eg.newestGetMessagesResult( + foundOldest: true, messages: [message]).toJson()); + await tester.pumpWidget(TestZulipApp(accountId: eg.selfAccount.id, + navigatorObservers: [transitionDurationObserver], + child: MessageListPage(initNarrow: CombinedFeedNarrow()))); + + store.setServerEmojiData(eg.serverEmojiDataPopularPlus( + ServerEmojiData(codeToNames: { + '1f4a4': ['zzz', 'sleepy'], // (just 'zzz' in real data) + }))); + + // global store, per-account store, and message list get loaded + await tester.pumpAndSettle(); + + await tester.longPress(find.byType(MessageContent)); + await transitionDurationObserver.pumpPastTransition(tester); + + await store.handleEvent(RealmEmojiUpdateEvent(id: 1, realmEmoji: { + '1': eg.realmEmojiItem(emojiCode: '1', emojiName: 'buzzing'), + })); + + await tester.tap(find.byIcon(ZulipIcons.see_who_reacted)); + await tester.pumpAndSettle(); + await tester.ensureVisible(find.byType(ViewReactions)); + } + + void checkUserList(WidgetTester tester, String emojiName, List expectUsers) { + final findPanel = find.semantics.byPredicate((node) => + node.role == SemanticsRole.tabPanel + && node.label.contains('Votes for $emojiName')); + + final panel = findPanel.evaluate().single; + check(panel).containsSemantics(label: 'Votes for $emojiName (${expectUsers.length})'); + + for (final user in expectUsers) { + check(find.semantics.descendant( + of: findPanel, + matching: find.semantics.byLabel(user.fullName)), + because: 'expect ${user.fullName}').findsOne(); + } + } + + testWidgets('smoke', (tester) async { + final reactions = [ + Reaction.fromJson({'user_id': eg.selfUser.userId, ...i1}), + Reaction.fromJson({'user_id': eg.selfUser.userId, ...z1}), + Reaction.fromJson({'user_id': eg.selfUser.userId, ...u1}), + Reaction.fromJson({'user_id': eg.selfUser.userId, ...u2}), + + Reaction.fromJson({'user_id': eg.otherUser.userId, ...i1}), + Reaction.fromJson({'user_id': eg.otherUser.userId, ...z1}), + Reaction.fromJson({'user_id': eg.otherUser.userId, ...u2}), + Reaction.fromJson({'user_id': eg.otherUser.userId, ...u3}), + ]; + + final message = eg.streamMessage(reactions: reactions); + await setupViewReactionsSheet(tester, message: message, usersExcludingSelf: [eg.otherUser]); + + checkViewReactionsEmojiItem(tester, emojiName: nameOf(i1), expectCount: 2, expectSelected: true); + checkViewReactionsEmojiItem(tester, emojiName: nameOf(z1), expectCount: 2, expectSelected: false); + checkViewReactionsEmojiItem(tester, emojiName: nameOf(u1), expectCount: 1, expectSelected: false); + checkViewReactionsEmojiItem(tester, emojiName: nameOf(u2), expectCount: 2, expectSelected: false); + checkViewReactionsEmojiItem(tester, emojiName: nameOf(u3), expectCount: 1, expectSelected: false); + + checkUserList(tester, nameOf(i1), [eg.selfUser, eg.otherUser]); + tester.semantics.tap(findViewReactionsEmojiItem(nameOf(z1))); + await tester.pump(); + checkUserList(tester, nameOf(z1), [eg.selfUser, eg.otherUser]); + tester.semantics.tap(findViewReactionsEmojiItem(nameOf(u1))); + await tester.pump(); + checkUserList(tester, nameOf(u1), [eg.selfUser]); + tester.semantics.tap(findViewReactionsEmojiItem(nameOf(u3))); + await tester.pump(); + checkUserList(tester, nameOf(u3), [eg.otherUser]); + + // TODO(upstream) Do this in an addTearDown once we can: + // https://github.com/flutter/flutter/issues/123189 + debugNetworkImageHttpClientProvider = null; + }); + + // TODO test last-vote-removed on selected emoji + // TODO test message deleted + // TODO test that tapping a user opens their profile + // TODO test emoji list's scroll-into-view logic + // TODO test expired event queue/refresh + }); }