From 94fca64a4ccc855e9758431278b2d6ebb5b7a435 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Wed, 9 Jul 2025 14:01:42 -0700 Subject: [PATCH 01/12] 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 73676b598e..867978dd8f 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 cca31ccc07..7e1b299b81 100644 --- a/lib/widgets/emoji_reaction.dart +++ b/lib/widgets/emoji_reaction.dart @@ -512,7 +512,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 128f13903d29e50d1ca8a72ee847e403ff68b71a Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Wed, 9 Jul 2025 15:55:23 -0700 Subject: [PATCH 02/12] 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 16512 -> 16968 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 d38427fc5eed2edeaf4c41531719d4d2750e75a8..b4cca7838c3b28c6f1cf438bd7b7db720a5810de 100644 GIT binary patch delta 2431 zcmb7GOH5p46h8l*C-Z_C?hFhR3UhI2p=FrMbl!I!FvD|rm=;`FI)!Oz3IzdMT8&ea z#u{Txn$g5Wqe&Kq$Vyj9N4-PGr~6k=t;UI1lt{llc|0J`{B)w3$an}^r{e{R5e7FPsFIap+1 z0efCo%L@ic6^sL-*M0;@&mF3;VOXTRy@ciXz`GEp3PV*Gz6BQ9V8>s~O4i^Prj*cSyJ)3}91$Dfr8=0qJk)&R%coI=m&jZ~H^d5adl~?tSGA|&Xf&}jU zDj*Lef$}IScvZ?n<9YsDX%~k)S)$#Csl{M`Q(h_Nz=nZhg}vc3mTNe;QFY{`;^C3 z9UNYTJs_U5gpmF%&tT6b?SSFM9mS06ap!&IPQq}bAh7BQpC*xB!hL_Z{m@E5o`-&| zPwmRy%cS}u$s>p%6{0|T;j4d!0-lC|oJ1%YOi>Q)9zh_(2$$Dr0+q}ojZw5Mj^}2)5;vbW z@Yk%dk~I~ZVLvpOOlJmiuuBaqvBE?&pB){GPmWiDHCHNACVSb&qte~ycl;(nvl=D9 ziyB41Pir&;KBv(jI9}N*CBaJ?rNPf=lmS1h(E#{@Mmg|vWqt$ogU)L-0=}qG3VcbU zVektYDd5W*#lTlIDuBPH(FFLaM)(%dnnro>b&ZrP7O!hG3Vu!;U!-gyW|03GdJA!Rx zGr&o=rMr!%^Z;6|;On+VZ_+zxbx`(*Mk(m)VWAXj2!#_&Ze^l_bu*v8!EG{{jFPX@ zA7K6z^BiQJ29F#GM}jHV%1r)VmSTPF(04ogWKRn>liGbf!ZD}h^D8VAHu$A>kGt#? zTbZ0<{G_kl8DehH)Gmh=#*8hj?^vh@ALH9Duh(U(x7q6NdY#NF+H9hgIlajmi>*^W zdbrLZ*z^Y^tIlYw?{u1s7GI0eqTBh=6=)K=q=c_ql6?tjm%M=vaaxk=g}XM_?u6MY zHi%ZaEV1t0M<#aN<=wH{ryY%6;gMJJs=_X3m3zNt7F$hAi?vqgkUTnv-e9oX4JL;{ z^f?6fiA#i&-7Z<`h8wXX;AOXEX~rAqvWcFZTUODEKg7VcoICfsJ)+myEsWTmGc$I# v#Xy^K&*!!H2jpkvGl9LlxsrN=)zz+*O0$<1mS#kuv;eC41cBmhu~`#=H?^vxVpO<^0lI({8F?B)TEu3azeOT6^c(pRb9; zKjnG<%8ib|l$`-tbblSVb>-JSW2)F@%ool{t(=e#lZzy~}d#x%b@P zd-^?=R7j0^IW8w9fXkpnZI=nC66}wmY01NABqWRdDAFxHX(h2X zWKd!RmqQM*^8nm~tc*xIvw{i9h^^j&g9hA=qZENA$$b=+DC#MxM@M%wkalUJCMIEL zA=UgRpf{N%q>+7-%;Jn;to;NL!9yPwX)F>p`xs-0pv?l2Jb_Xe^$3|b%d9?b6OG|~ z7{z|tV6v}IO4=%&tldwzF=*9FgKR}hL()8tF^|zUQzqp;Y+|Hi+TpjzJyb=Tn3KX7 zZlf4AqpmS$Fx4E}q=k+Jajo4_@*w-_{$8oNvtDkGkNP}{<&&I}=VhJOq}T3u5I5ys z>uHI~DXi1jYbD?<;L?)Eh$n%I6b`gkmOMfT+UXDu)6zw7WutC8NBi*L(t}nE+8$;a zVu;!_W4|nK%eLHzn+FFVzY%g3|E*%>X|F)N?^T56$0rB_8yb|tk4VyayZva7WyksfiH zrdicd0X-q(yip!3+&rb2`Kgk{892_1o?vFdq35HN2>GkA=G;L{cPk`$)}05T(K6>a z%N|BO=jvntH!%V$t>~#~l!q7(4sj=1arQ0rYwh-wzZs?Pg;j_9j7>4dhs@*pxY*@Q z8Uw{e@9)L4m6v@odZ-n+RE{+-P`m zoc*ohR%Kc}+-&Nzr5^A7kt%Q*)b + + + + + diff --git a/lib/widgets/icons.dart b/lib/widgets/icons.dart index 1ba94dc389..a8180cd9ad 100644 --- a/lib/widgets/icons.dart +++ b/lib/widgets/icons.dart @@ -147,41 +147,44 @@ abstract final class ZulipIcons { /// The Zulip custom icon "search". static const IconData search = IconData(0xf129, fontFamily: "Zulip Icons"); + /// The Zulip custom icon "see_who_reacted". + static const IconData see_who_reacted = IconData(0xf12a, fontFamily: "Zulip Icons"); + /// The Zulip custom icon "send". - static const IconData send = IconData(0xf12a, fontFamily: "Zulip Icons"); + static const IconData send = IconData(0xf12b, fontFamily: "Zulip Icons"); /// The Zulip custom icon "settings". - static const IconData settings = IconData(0xf12b, fontFamily: "Zulip Icons"); + static const IconData settings = IconData(0xf12c, fontFamily: "Zulip Icons"); /// The Zulip custom icon "share". - static const IconData share = IconData(0xf12c, fontFamily: "Zulip Icons"); + static const IconData share = IconData(0xf12d, fontFamily: "Zulip Icons"); /// The Zulip custom icon "share_ios". - static const IconData share_ios = IconData(0xf12d, fontFamily: "Zulip Icons"); + static const IconData share_ios = IconData(0xf12e, fontFamily: "Zulip Icons"); /// The Zulip custom icon "smile". - static const IconData smile = IconData(0xf12e, fontFamily: "Zulip Icons"); + static const IconData smile = IconData(0xf12f, fontFamily: "Zulip Icons"); /// The Zulip custom icon "star". - static const IconData star = IconData(0xf12f, fontFamily: "Zulip Icons"); + static const IconData star = IconData(0xf130, fontFamily: "Zulip Icons"); /// The Zulip custom icon "star_filled". - static const IconData star_filled = IconData(0xf130, fontFamily: "Zulip Icons"); + static const IconData star_filled = IconData(0xf131, fontFamily: "Zulip Icons"); /// The Zulip custom icon "three_person". - static const IconData three_person = IconData(0xf131, fontFamily: "Zulip Icons"); + static const IconData three_person = IconData(0xf132, fontFamily: "Zulip Icons"); /// The Zulip custom icon "topic". - static const IconData topic = IconData(0xf132, fontFamily: "Zulip Icons"); + static const IconData topic = IconData(0xf133, fontFamily: "Zulip Icons"); /// The Zulip custom icon "topics". - static const IconData topics = IconData(0xf133, fontFamily: "Zulip Icons"); + static const IconData topics = IconData(0xf134, fontFamily: "Zulip Icons"); /// The Zulip custom icon "two_person". - static const IconData two_person = IconData(0xf134, fontFamily: "Zulip Icons"); + static const IconData two_person = IconData(0xf135, fontFamily: "Zulip Icons"); /// The Zulip custom icon "unmute". - static const IconData unmute = IconData(0xf135, fontFamily: "Zulip Icons"); + static const IconData unmute = IconData(0xf136, fontFamily: "Zulip Icons"); // END GENERATED ICON DATA } From b8136f85b19714fdeebf8a87aec5aa194750950b Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Thu, 24 Jul 2025 14:19:12 -0700 Subject: [PATCH 03/12] 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 867978dd8f..61127d4f6a 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 cccb6a87562dceb7ce943a17e3917add60e13988 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Thu, 24 Jul 2025 15:11:41 -0700 Subject: [PATCH 04/12] 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 a1bea9d940300287924f2851823c412cb2a04653 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Thu, 24 Jul 2025 15:23:12 -0700 Subject: [PATCH 05/12] 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 927eabfd7095d196ac9576a79193b4e4657c587b Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Thu, 24 Jul 2025 17:21:43 -0700 Subject: [PATCH 06/12] 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 7aadc11ede10d9879c23edb1d7b12801d2a764d0 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Wed, 9 Jul 2025 13:54:32 -0700 Subject: [PATCH 07/12] 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 0f762fc1cd..15a5bd7b04 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -140,6 +140,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 0432baa8f7..b4fb84e7ec 100644 --- a/lib/generated/l10n/zulip_localizations.dart +++ b/lib/generated/l10n/zulip_localizations.dart @@ -341,6 +341,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 00a08887a2..f087aaa19a 100644 --- a/lib/generated/l10n/zulip_localizations_ar.dart +++ b/lib/generated/l10n/zulip_localizations_ar.dart @@ -121,6 +121,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 a769a6e862..499e10db87 100644 --- a/lib/generated/l10n/zulip_localizations_de.dart +++ b/lib/generated/l10n/zulip_localizations_de.dart @@ -124,6 +124,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 f85fff71dc..5dc5a83ddb 100644 --- a/lib/generated/l10n/zulip_localizations_en.dart +++ b/lib/generated/l10n/zulip_localizations_en.dart @@ -121,6 +121,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 46c3aa2885..88500755e3 100644 --- a/lib/generated/l10n/zulip_localizations_fr.dart +++ b/lib/generated/l10n/zulip_localizations_fr.dart @@ -121,6 +121,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 dc0db573a4..ca77f82762 100644 --- a/lib/generated/l10n/zulip_localizations_it.dart +++ b/lib/generated/l10n/zulip_localizations_it.dart @@ -123,6 +123,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 4fe6d68cd4..bc2dbaa02b 100644 --- a/lib/generated/l10n/zulip_localizations_ja.dart +++ b/lib/generated/l10n/zulip_localizations_ja.dart @@ -119,6 +119,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 2ec302486c..347f9ad5b5 100644 --- a/lib/generated/l10n/zulip_localizations_nb.dart +++ b/lib/generated/l10n/zulip_localizations_nb.dart @@ -121,6 +121,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 a084d59ae3..750ec80f75 100644 --- a/lib/generated/l10n/zulip_localizations_pl.dart +++ b/lib/generated/l10n/zulip_localizations_pl.dart @@ -124,6 +124,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 198d1b594f..5440b945c3 100644 --- a/lib/generated/l10n/zulip_localizations_ru.dart +++ b/lib/generated/l10n/zulip_localizations_ru.dart @@ -124,6 +124,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 d7b1580c21..667a3fa37c 100644 --- a/lib/generated/l10n/zulip_localizations_sk.dart +++ b/lib/generated/l10n/zulip_localizations_sk.dart @@ -121,6 +121,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 4102e374fe..d1aad71ef9 100644 --- a/lib/generated/l10n/zulip_localizations_sl.dart +++ b/lib/generated/l10n/zulip_localizations_sl.dart @@ -122,6 +122,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 8b682b7a92..9ef1000ed9 100644 --- a/lib/generated/l10n/zulip_localizations_uk.dart +++ b/lib/generated/l10n/zulip_localizations_uk.dart @@ -125,6 +125,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 3a3ce62aab..65fdc05242 100644 --- a/lib/generated/l10n/zulip_localizations_zh.dart +++ b/lib/generated/l10n/zulip_localizations_zh.dart @@ -121,6 +121,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 61127d4f6a..92ac06acd9 100644 --- a/lib/widgets/action_sheet.dart +++ b/lib/widgets/action_sheet.dart @@ -693,6 +693,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). @@ -710,6 +713,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), @@ -944,6 +949,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 7e1b299b81..fae33d7e85 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, @@ -595,3 +608,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 561c136473..d65ac20507 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 @@ -1108,6 +1114,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 + }); } From 87518f10efeff17149a835993f453b2b7f4c3d9e Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Mon, 14 Jul 2025 14:29:39 +0430 Subject: [PATCH 08/12] icons: Add `check_check` icon, from Web Figma file Figma link: https://www.figma.com/design/jbNOiBWvbtLuHaiTj4CW0G/Zulip-Web-App?node-id=7352-690&t=OXW354YOc17R7B6G-0 --- assets/icons/ZulipIcons.ttf | Bin 16968 -> 17160 bytes assets/icons/check_check.svg | 4 ++ lib/widgets/icons.dart | 95 ++++++++++++++++++----------------- 3 files changed, 53 insertions(+), 46 deletions(-) create mode 100644 assets/icons/check_check.svg diff --git a/assets/icons/ZulipIcons.ttf b/assets/icons/ZulipIcons.ttf index b4cca7838c3b28c6f1cf438bd7b7db720a5810de..1285c9ba7f7625567a47d8f337d03232393c94de 100644 GIT binary patch delta 2275 zcmb7FTWnKx82|pKr>B>-T{pU}3>~B6qH}I+;?|z^v}ax0t=+ndcu9x>>ev`zH(}uL zrAsu#7()VmkQic&SqM)iGJ=SE*aI=V$&0uUg2bpHh7j-tUqpZ3*?*K5AMDBReE;*^ zZ>Q(_hvK7?qDX>>DrkXhw6k~j8_8pj<{lG?2$9`Aw7a$a%^R;b5NRoB?H`>g%sxK% z^#LNsJ;-kzoIEjpyG~mn@=Xx6&QFXL4jljO+C}Kz1v)0ca0+z<=Vf5)#MIpUt#AMN z8~zp%BQ-fQY83MCzjcYodkqSUQ-%3iaY%GP7{YVIbYW_&=8D-)WZWfEI%j8&&YfBw z?<6uIS00{TZuE{=6`+a__WyA9^!+w5QmPjRi=R^^ZKVseDm1ZE>=hTq1EpF?DGyb* z8dRs%1+tTeS|~$%X^s@~Qxum@T@<4Pnbb{5oTAi2DN5sPLzBuHWxgH!B(4bcLN5lp z9=7Mcx}PyHEZ1>T$zeIpSdQZetWlU=ATL5j$go11aAiWChh~Oegoa63*bji($Vqi5 zY%^$t;z+I^GzCcmeD{Epqb)cUbvVgc>LCOOAnYKNVsO)qs~Rc>)RVLUIxOKt8YqaK z=tw&Yr(FLK-0L`*R0Fw=$`ZJD!P<=^bn2s5V3CG}X?f4%9!1i65t(#AsS8RmR3h2n zzG^wjBX}u5W*Q$5m+!!28JlAEB#DRG_8;T%U`ordXo*u+teZlxch z5W2!c?5AQLVGS77LY;H&gDKZoPa83@2txC;*m(qU4qr#Er~LKtxX`&>X#9RULJRZ> zoux17C2ZDOwO#PR<#F{qf9{ik^sx~|FrpBxtphKVL9Gaow)i}V9++n6Wn^r^Fo|cL zW`>%OB)f~kVHyi+e`>SS>Vmai)QrkaSQylX6BiLh*bwYzk>+_SuFxv|L4OHV_#p_w z39nTSfqM~(opVw#v?^`|T9a~`Zq)o=+MH5Z(}%U?=K=WB;pdt6rHHlQ#aIu@En44U zuauO{*53{nQZ3t*gZa@V4zTu`@mt|Qmm=`U8xnz(yR;Uo(i*tj|0*5P9)r7Px@5VDr!tlZa=%;Ns^~0-a#ppzoIAxn9 zi&rjyo#vTZF9w7=V32@31A|JQT***FHK#CmwZ{+|I_f7y|U6Dgz00qvkZGpF{X8 z65-AaS!pE^d;l-&Fb$UWI;ZUThZS0B#8ub?`@UYo>`tX&m&nHT{7^KL>>ubF9)x_M zwBT?QJ)D*82H)`;2KGtBKnoK1q|iGOeW0Tfy`Tpq4A3zN6Legn8}y(=3KX9k3u(|p zMZN(&z+s6T=%hpfbV?!%IxP_gosrN%XC-(F?@A1T9+8NG9+k*|&PfR%wwzj_Z-#EMd$CXjO#P^DfgchZ&%#(ba<|M{ob7Syf5s#Tv=Tiul&^?@b9Ur ztIAa0*)YE0Y9JT*DsaE%a!?5#ul3a~*S0O=_P?&Bi0?bKxN2*{w`3c(rK3f0h#f7$ zrvx{PV0)*Cixx!#YkOD=SR7luM}!(ZPqJ5>!C;l zlX<^*8bbn#-?%i*UmJA(==F*wXKk>`uDPE4yv4II>@_`DI%VlW^{37F;GaxPx)LlP3cj{0eb@vOA0Q%eS%+3@Kp8WahC3J5?!C4NxhBd|dGSo9WUs}5P z)jxlOcLpEHxnl?7#qq{rjQ!_OxG-N_S}}H` z%~jW`>#nuOIwW<{DBER3_DV@iX_hc|M4~bvgOZS>q*#SzNQNcN+C`DpG}g}RI8Skh zKuTkg!~%P*tM5L8m<=5lOFTAk#s-e#*ezS+3Gw4HBr)4%0;mM?0-71wibg_m*pI_pNc)ABUo*KBs!Rj|iPo+9%E7BT5#rAdUTUOT!+<*Zzk@;9;~KguuimvgK`HHzC2 z94WMhG(e%^wwtO{gVc<*@c*Z+0j+8J8Cu!0^Ml-HWaHAtxGpoHD zP1HhxsxOnyzK2?bu+%vSA=O$`F#}FpHOyuzpffbiJ7;0xVpmSF^wFy{GrYy_SO&oA z2x%2+K$>$mAtjKm$&>6=@+c@;<~-AG@zf5hlOS&51XexKxoVZic-|ZHKIYh>$az2w z+-rYopOxw-B|{Kd8=@eV`B^3BP8h+;8F#MIW^S$o40%C0N%2o#|S6MOr>f0DBg2~taX|omJF%nXIu3+wDiERh^eV>OtIM+y_+Oe#MXog7F0M&-PxuH2|GxvuJyYZBV$&;Y#X zPzXNlP!@i`p%FNrZ<}K98HW<^Lk=b3haDP*&pMQbA1Uht8iHPOXcRu@&>(!?p&b0E zLvi>qha&I=hYIlH4o$!p9pb+vCmhPaOAf_TEM9gf4?pS982lB7_>0PtLt3Fz4vjBw zZ#Zje3IVb$$tpjmYi7`V(fq)??do++xfWeFtkc$?b<=fM+-dhm?iKH}_hP+O|AMdJ z`@EsE;atO=#-+yZ{9gY-|Ls62uoAf2bfM`+b4T;7b=LY1TY6fSTD`4vt)I4)+OD+s sw_o4z>H~N8gGc{45m}xMd~Gd%-11&8|I}#meC3Da#BwlogJG0^0ToL#F#rGn diff --git a/assets/icons/check_check.svg b/assets/icons/check_check.svg new file mode 100644 index 0000000000..3d7b4a59d6 --- /dev/null +++ b/assets/icons/check_check.svg @@ -0,0 +1,4 @@ + + + \ No newline at end of file diff --git a/lib/widgets/icons.dart b/lib/widgets/icons.dart index a8180cd9ad..887c115cee 100644 --- a/lib/widgets/icons.dart +++ b/lib/widgets/icons.dart @@ -48,143 +48,146 @@ abstract final class ZulipIcons { /// The Zulip custom icon "check". static const IconData check = IconData(0xf108, fontFamily: "Zulip Icons"); + /// The Zulip custom icon "check_check". + static const IconData check_check = IconData(0xf109, fontFamily: "Zulip Icons"); + /// The Zulip custom icon "check_circle_checked". - static const IconData check_circle_checked = IconData(0xf109, fontFamily: "Zulip Icons"); + static const IconData check_circle_checked = IconData(0xf10a, fontFamily: "Zulip Icons"); /// The Zulip custom icon "check_circle_unchecked". - static const IconData check_circle_unchecked = IconData(0xf10a, fontFamily: "Zulip Icons"); + static const IconData check_circle_unchecked = IconData(0xf10b, fontFamily: "Zulip Icons"); /// The Zulip custom icon "check_remove". - static const IconData check_remove = IconData(0xf10b, fontFamily: "Zulip Icons"); + static const IconData check_remove = IconData(0xf10c, fontFamily: "Zulip Icons"); /// The Zulip custom icon "chevron_down". - static const IconData chevron_down = IconData(0xf10c, fontFamily: "Zulip Icons"); + static const IconData chevron_down = IconData(0xf10d, fontFamily: "Zulip Icons"); /// The Zulip custom icon "chevron_right". - static const IconData chevron_right = IconData(0xf10d, fontFamily: "Zulip Icons"); + static const IconData chevron_right = IconData(0xf10e, fontFamily: "Zulip Icons"); /// The Zulip custom icon "clock". - static const IconData clock = IconData(0xf10e, fontFamily: "Zulip Icons"); + static const IconData clock = IconData(0xf10f, fontFamily: "Zulip Icons"); /// The Zulip custom icon "contacts". - static const IconData contacts = IconData(0xf10f, fontFamily: "Zulip Icons"); + static const IconData contacts = IconData(0xf110, fontFamily: "Zulip Icons"); /// The Zulip custom icon "copy". - static const IconData copy = IconData(0xf110, fontFamily: "Zulip Icons"); + static const IconData copy = IconData(0xf111, fontFamily: "Zulip Icons"); /// The Zulip custom icon "edit". - static const IconData edit = IconData(0xf111, fontFamily: "Zulip Icons"); + static const IconData edit = IconData(0xf112, fontFamily: "Zulip Icons"); /// The Zulip custom icon "eye". - static const IconData eye = IconData(0xf112, fontFamily: "Zulip Icons"); + static const IconData eye = IconData(0xf113, fontFamily: "Zulip Icons"); /// The Zulip custom icon "eye_off". - static const IconData eye_off = IconData(0xf113, fontFamily: "Zulip Icons"); + static const IconData eye_off = IconData(0xf114, fontFamily: "Zulip Icons"); /// The Zulip custom icon "follow". - static const IconData follow = IconData(0xf114, fontFamily: "Zulip Icons"); + static const IconData follow = IconData(0xf115, fontFamily: "Zulip Icons"); /// The Zulip custom icon "format_quote". - static const IconData format_quote = IconData(0xf115, fontFamily: "Zulip Icons"); + static const IconData format_quote = IconData(0xf116, fontFamily: "Zulip Icons"); /// The Zulip custom icon "globe". - static const IconData globe = IconData(0xf116, fontFamily: "Zulip Icons"); + static const IconData globe = IconData(0xf117, fontFamily: "Zulip Icons"); /// The Zulip custom icon "group_dm". - static const IconData group_dm = IconData(0xf117, fontFamily: "Zulip Icons"); + static const IconData group_dm = IconData(0xf118, fontFamily: "Zulip Icons"); /// The Zulip custom icon "hash_italic". - static const IconData hash_italic = IconData(0xf118, fontFamily: "Zulip Icons"); + static const IconData hash_italic = IconData(0xf119, fontFamily: "Zulip Icons"); /// The Zulip custom icon "hash_sign". - static const IconData hash_sign = IconData(0xf119, fontFamily: "Zulip Icons"); + static const IconData hash_sign = IconData(0xf11a, fontFamily: "Zulip Icons"); /// The Zulip custom icon "image". - static const IconData image = IconData(0xf11a, fontFamily: "Zulip Icons"); + static const IconData image = IconData(0xf11b, fontFamily: "Zulip Icons"); /// The Zulip custom icon "inbox". - static const IconData inbox = IconData(0xf11b, fontFamily: "Zulip Icons"); + static const IconData inbox = IconData(0xf11c, fontFamily: "Zulip Icons"); /// The Zulip custom icon "info". - static const IconData info = IconData(0xf11c, fontFamily: "Zulip Icons"); + static const IconData info = IconData(0xf11d, fontFamily: "Zulip Icons"); /// The Zulip custom icon "inherit". - static const IconData inherit = IconData(0xf11d, fontFamily: "Zulip Icons"); + static const IconData inherit = IconData(0xf11e, fontFamily: "Zulip Icons"); /// The Zulip custom icon "language". - static const IconData language = IconData(0xf11e, fontFamily: "Zulip Icons"); + static const IconData language = IconData(0xf11f, fontFamily: "Zulip Icons"); /// The Zulip custom icon "link". - static const IconData link = IconData(0xf11f, fontFamily: "Zulip Icons"); + static const IconData link = IconData(0xf120, fontFamily: "Zulip Icons"); /// The Zulip custom icon "lock". - static const IconData lock = IconData(0xf120, fontFamily: "Zulip Icons"); + static const IconData lock = IconData(0xf121, fontFamily: "Zulip Icons"); /// The Zulip custom icon "menu". - static const IconData menu = IconData(0xf121, fontFamily: "Zulip Icons"); + static const IconData menu = IconData(0xf122, fontFamily: "Zulip Icons"); /// The Zulip custom icon "message_checked". - static const IconData message_checked = IconData(0xf122, fontFamily: "Zulip Icons"); + static const IconData message_checked = IconData(0xf123, fontFamily: "Zulip Icons"); /// The Zulip custom icon "message_feed". - static const IconData message_feed = IconData(0xf123, fontFamily: "Zulip Icons"); + static const IconData message_feed = IconData(0xf124, fontFamily: "Zulip Icons"); /// The Zulip custom icon "mute". - static const IconData mute = IconData(0xf124, fontFamily: "Zulip Icons"); + static const IconData mute = IconData(0xf125, fontFamily: "Zulip Icons"); /// The Zulip custom icon "person". - static const IconData person = IconData(0xf125, fontFamily: "Zulip Icons"); + static const IconData person = IconData(0xf126, fontFamily: "Zulip Icons"); /// The Zulip custom icon "plus". - static const IconData plus = IconData(0xf126, fontFamily: "Zulip Icons"); + static const IconData plus = IconData(0xf127, fontFamily: "Zulip Icons"); /// The Zulip custom icon "read_receipts". - static const IconData read_receipts = IconData(0xf127, fontFamily: "Zulip Icons"); + static const IconData read_receipts = IconData(0xf128, fontFamily: "Zulip Icons"); /// The Zulip custom icon "remove". - static const IconData remove = IconData(0xf128, fontFamily: "Zulip Icons"); + static const IconData remove = IconData(0xf129, fontFamily: "Zulip Icons"); /// The Zulip custom icon "search". - static const IconData search = IconData(0xf129, fontFamily: "Zulip Icons"); + static const IconData search = IconData(0xf12a, fontFamily: "Zulip Icons"); /// The Zulip custom icon "see_who_reacted". - static const IconData see_who_reacted = IconData(0xf12a, fontFamily: "Zulip Icons"); + static const IconData see_who_reacted = IconData(0xf12b, fontFamily: "Zulip Icons"); /// The Zulip custom icon "send". - static const IconData send = IconData(0xf12b, fontFamily: "Zulip Icons"); + static const IconData send = IconData(0xf12c, fontFamily: "Zulip Icons"); /// The Zulip custom icon "settings". - static const IconData settings = IconData(0xf12c, fontFamily: "Zulip Icons"); + static const IconData settings = IconData(0xf12d, fontFamily: "Zulip Icons"); /// The Zulip custom icon "share". - static const IconData share = IconData(0xf12d, fontFamily: "Zulip Icons"); + static const IconData share = IconData(0xf12e, fontFamily: "Zulip Icons"); /// The Zulip custom icon "share_ios". - static const IconData share_ios = IconData(0xf12e, fontFamily: "Zulip Icons"); + static const IconData share_ios = IconData(0xf12f, fontFamily: "Zulip Icons"); /// The Zulip custom icon "smile". - static const IconData smile = IconData(0xf12f, fontFamily: "Zulip Icons"); + static const IconData smile = IconData(0xf130, fontFamily: "Zulip Icons"); /// The Zulip custom icon "star". - static const IconData star = IconData(0xf130, fontFamily: "Zulip Icons"); + static const IconData star = IconData(0xf131, fontFamily: "Zulip Icons"); /// The Zulip custom icon "star_filled". - static const IconData star_filled = IconData(0xf131, fontFamily: "Zulip Icons"); + static const IconData star_filled = IconData(0xf132, fontFamily: "Zulip Icons"); /// The Zulip custom icon "three_person". - static const IconData three_person = IconData(0xf132, fontFamily: "Zulip Icons"); + static const IconData three_person = IconData(0xf133, fontFamily: "Zulip Icons"); /// The Zulip custom icon "topic". - static const IconData topic = IconData(0xf133, fontFamily: "Zulip Icons"); + static const IconData topic = IconData(0xf134, fontFamily: "Zulip Icons"); /// The Zulip custom icon "topics". - static const IconData topics = IconData(0xf134, fontFamily: "Zulip Icons"); + static const IconData topics = IconData(0xf135, fontFamily: "Zulip Icons"); /// The Zulip custom icon "two_person". - static const IconData two_person = IconData(0xf135, fontFamily: "Zulip Icons"); + static const IconData two_person = IconData(0xf136, fontFamily: "Zulip Icons"); /// The Zulip custom icon "unmute". - static const IconData unmute = IconData(0xf136, fontFamily: "Zulip Icons"); + static const IconData unmute = IconData(0xf137, fontFamily: "Zulip Icons"); // END GENERATED ICON DATA } From 7fe57ae7169343a368352fc3e5980e86a31865a1 Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Tue, 15 Jul 2025 14:05:36 +0430 Subject: [PATCH 09/12] deps: Add styled_text --- pubspec.lock | 16 ++++++++++++++++ pubspec.yaml | 1 + 2 files changed, 17 insertions(+) diff --git a/pubspec.lock b/pubspec.lock index d9ad3f0ae9..e179994e64 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1059,6 +1059,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + styled_text: + dependency: "direct main" + description: + name: styled_text + sha256: fd624172cf629751b4f171dd0ecf9acf02a06df3f8a81bb56c0caa4f1df706c3 + url: "https://pub.dev" + source: hosted + version: "8.1.0" sync_http: dependency: transitive description: @@ -1339,6 +1347,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.5.0" + xmlstream: + dependency: transitive + description: + name: xmlstream + sha256: cfc14e3f256997897df9481ae630d94c2d85ada5187ebeb868bb1aabc2c977b4 + url: "https://pub.dev" + source: hosted + version: "1.1.1" yaml: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 397342ca76..0a3abfedff 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -59,6 +59,7 @@ dependencies: share_plus_platform_interface: ^6.0.0 sqlite3: ^2.4.0 sqlite3_flutter_libs: ^0.5.13 + styled_text: ^8.1.0 url_launcher: ^6.1.11 url_launcher_android: ">=6.1.0" video_player: ^2.10.0 From 76fe89ffcf23361ff7f9dc3b26413e11843839e6 Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Wed, 30 Jul 2025 22:56:02 +0430 Subject: [PATCH 10/12] api: Add route getReadReceipts --- lib/api/route/messages.dart | 20 ++++++++++++++++++++ lib/api/route/messages.g.dart | 12 ++++++++++++ test/api/route/messages_test.dart | 11 +++++++++++ 3 files changed, 43 insertions(+) diff --git a/lib/api/route/messages.dart b/lib/api/route/messages.dart index 05364951cd..5d128317fe 100644 --- a/lib/api/route/messages.dart +++ b/lib/api/route/messages.dart @@ -435,3 +435,23 @@ class UpdateMessageFlagsForNarrowResult { Map toJson() => _$UpdateMessageFlagsForNarrowResultToJson(this); } + +/// https://zulip.com/api/get-read-receipts +Future getReadReceipts(ApiConnection connection, { + required int messageId, +}) { + return connection.get('getReadReceipts', GetReadReceiptsResult.fromJson, + 'messages/$messageId/read_receipts', null); +} + +@JsonSerializable(fieldRename: FieldRename.snake) +class GetReadReceiptsResult { + const GetReadReceiptsResult({required this.userIds}); + + final List userIds; + + factory GetReadReceiptsResult.fromJson(Map json) => + _$GetReadReceiptsResultFromJson(json); + + Map toJson() => _$GetReadReceiptsResultToJson(this); +} diff --git a/lib/api/route/messages.g.dart b/lib/api/route/messages.g.dart index 0df3e678e6..4ba646ec70 100644 --- a/lib/api/route/messages.g.dart +++ b/lib/api/route/messages.g.dart @@ -89,6 +89,18 @@ Map _$UpdateMessageFlagsForNarrowResultToJson( 'found_newest': instance.foundNewest, }; +GetReadReceiptsResult _$GetReadReceiptsResultFromJson( + Map json, +) => GetReadReceiptsResult( + userIds: (json['user_ids'] as List) + .map((e) => (e as num).toInt()) + .toList(), +); + +Map _$GetReadReceiptsResultToJson( + GetReadReceiptsResult instance, +) => {'user_ids': instance.userIds}; + const _$AnchorCodeEnumMap = { AnchorCode.newest: 'newest', AnchorCode.oldest: 'oldest', diff --git a/test/api/route/messages_test.dart b/test/api/route/messages_test.dart index f00bf4428f..7e35d260e7 100644 --- a/test/api/route/messages_test.dart +++ b/test/api/route/messages_test.dart @@ -829,4 +829,15 @@ void main() { }); }); }); + + test('smoke getReadReceipts', () { + return FakeApiConnection.with_((connection) async { + final response = GetReadReceiptsResult(userIds: [7, 6543, 210]); + connection.prepare(json: response.toJson()); + await getReadReceipts(connection, messageId: 123321); + check(connection.takeRequests()).single.isA() + ..method.equals('GET') + ..url.path.equals('/api/v1/messages/123321/read_receipts'); + }); + }); } From 10641e585e9d08950443e41b5c128e90f4694925 Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Tue, 15 Jul 2025 14:08:47 +0430 Subject: [PATCH 11/12] action_sheet: Rename BottomSheetHeaderPlainText to BottomSheetInfoText This way, it can be used for purposes other than being a header, such as in the next commits, when read receipts fail to load, we use this widget in the center of bottom sheet to give feedback to the user. Additional changes: - Adds a TextAlign property for controlling the alignment. - Removes vertical padding so callers can decide about it. But keeps the horizontal padding of 16 as it is the default padding of our bottoms sheets. --- lib/widgets/action_sheet.dart | 15 ++++++++++----- lib/widgets/emoji_reaction.dart | 4 ++-- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/lib/widgets/action_sheet.dart b/lib/widgets/action_sheet.dart index 92ac06acd9..21f29ee81c 100644 --- a/lib/widgets/action_sheet.dart +++ b/lib/widgets/action_sheet.dart @@ -98,26 +98,31 @@ void _showActionSheet( }); } -/// A header for a bottom sheet with a multiline UI string. +/// A plain text widget for a bottom sheet with a multiline UI string. /// -/// Assumes 8px padding below the top of the bottom sheet. +/// Use it to present short, non-interactive explanatory messages to the user, +/// such as an error message or other feedback. +/// +/// Comes with built-in 16px horizontal padding. /// /// 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}); +class BottomSheetInfoText extends StatelessWidget { + const BottomSheetInfoText({super.key, required this.text, this.textAlign}); final String text; + final TextAlign? textAlign; @override Widget build(BuildContext context) { final designVariables = DesignVariables.of(context); return Padding( - padding: EdgeInsets.fromLTRB(16, 8, 16, 4), + padding: EdgeInsets.symmetric(horizontal: 16), child: SizedBox( width: double.infinity, child: Text( + textAlign: textAlign, style: TextStyle( color: designVariables.labelTime, fontSize: 17, diff --git a/lib/widgets/emoji_reaction.dart b/lib/widgets/emoji_reaction.dart index fae33d7e85..da72d66d13 100644 --- a/lib/widgets/emoji_reaction.dart +++ b/lib/widgets/emoji_reaction.dart @@ -773,8 +773,8 @@ class ViewReactionsHeader extends StatelessWidget { if (reactions == null || reactions.aggregated.isEmpty) { return Padding( - padding: const EdgeInsets.only(top: 8), - child: BottomSheetHeaderPlainText(text: zulipLocalizations.seeWhoReactedSheetNoReactions), + padding: const EdgeInsets.only(top: 16, bottom: 4), + child: BottomSheetInfoText(text: zulipLocalizations.seeWhoReactedSheetNoReactions), ); } From 961fcd8c7c81f56c23c74b8d813799d5d9f55943 Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Mon, 14 Jul 2025 14:35:23 +0430 Subject: [PATCH 12/12] action_sheet: Add 'View read receipts' button Fixes: #667 --- assets/l10n/app_en.arb | 23 ++ lib/generated/l10n/zulip_localizations.dart | 30 +++ .../l10n/zulip_localizations_ar.dart | 25 ++ .../l10n/zulip_localizations_de.dart | 25 ++ .../l10n/zulip_localizations_en.dart | 25 ++ .../l10n/zulip_localizations_fr.dart | 25 ++ .../l10n/zulip_localizations_it.dart | 25 ++ .../l10n/zulip_localizations_ja.dart | 25 ++ .../l10n/zulip_localizations_nb.dart | 25 ++ .../l10n/zulip_localizations_pl.dart | 25 ++ .../l10n/zulip_localizations_ru.dart | 25 ++ .../l10n/zulip_localizations_sk.dart | 25 ++ .../l10n/zulip_localizations_sl.dart | 25 ++ .../l10n/zulip_localizations_uk.dart | 25 ++ .../l10n/zulip_localizations_zh.dart | 25 ++ lib/widgets/action_sheet.dart | 17 ++ lib/widgets/read_receipts.dart | 215 ++++++++++++++++++ lib/widgets/theme.dart | 7 + test/widgets/action_sheet_test.dart | 38 ++++ test/widgets/read_receipts_test.dart | 179 +++++++++++++++ 20 files changed, 834 insertions(+) create mode 100644 lib/widgets/read_receipts.dart create mode 100644 test/widgets/read_receipts_test.dart diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index 15a5bd7b04..37bf0535d7 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -171,6 +171,29 @@ "num": {"type": "int", "example": "2"} } }, + "actionSheetOptionViewReadReceipts": "View read receipts", + "@actionSheetOptionViewReadReceipts": { + "description": "Label for the 'View read receipts' button in the message action sheet." + }, + "actionSheetReadReceipts": "Read receipts", + "@actionSheetReadReceipts": { + "description": "Title for the \"Read receipts\" bottom sheet." + }, + "actionSheetReadReceiptsReadCount": "This message has been read by {count} {count, plural, =1{person} other{people}}:", + "@actionSheetReadReceiptsReadCount": { + "description": "Label in the \"Read receipts\" bottom sheet when one or more people have read the message.", + "placeholders": { + "count": {"type": "int", "example": "1"} + } + }, + "actionSheetReadReceiptsZeroReadCount": "No one has read this message yet.", + "@actionSheetReadReceiptsZeroReadCount": { + "description": "Label in the \"Read receipts\" bottom sheet when no one has read the message." + }, + "actionSheetReadReceiptsErrorReadCount": "Failed to load read receipts.", + "@actionSheetReadReceiptsErrorReadCount": { + "description": "Label in the \"Read receipts\" bottom sheet when loading read receipts failed." + }, "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 b4fb84e7ec..4ee9f1247b 100644 --- a/lib/generated/l10n/zulip_localizations.dart +++ b/lib/generated/l10n/zulip_localizations.dart @@ -371,6 +371,36 @@ abstract class ZulipLocalizations { /// **'Votes for {emojiName} ({num})'** String seeWhoReactedSheetUserListLabel(String emojiName, int num); + /// Label for the 'View read receipts' button in the message action sheet. + /// + /// In en, this message translates to: + /// **'View read receipts'** + String get actionSheetOptionViewReadReceipts; + + /// Title for the "Read receipts" bottom sheet. + /// + /// In en, this message translates to: + /// **'Read receipts'** + String get actionSheetReadReceipts; + + /// Label in the "Read receipts" bottom sheet when one or more people have read the message. + /// + /// In en, this message translates to: + /// **'This message has been read by {count} {count, plural, =1{person} other{people}}:'** + String actionSheetReadReceiptsReadCount(int count); + + /// Label in the "Read receipts" bottom sheet when no one has read the message. + /// + /// In en, this message translates to: + /// **'No one has read this message yet.'** + String get actionSheetReadReceiptsZeroReadCount; + + /// Label in the "Read receipts" bottom sheet when loading read receipts failed. + /// + /// In en, this message translates to: + /// **'Failed to load read receipts.'** + String get actionSheetReadReceiptsErrorReadCount; + /// 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 f087aaa19a..c87c39dd02 100644 --- a/lib/generated/l10n/zulip_localizations_ar.dart +++ b/lib/generated/l10n/zulip_localizations_ar.dart @@ -148,6 +148,31 @@ class ZulipLocalizationsAr extends ZulipLocalizations { return 'Votes for $emojiName ($num)'; } + @override + String get actionSheetOptionViewReadReceipts => 'View read receipts'; + + @override + String get actionSheetReadReceipts => 'Read receipts'; + + @override + String actionSheetReadReceiptsReadCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'people', + one: 'person', + ); + return 'This message has been read by $count $_temp0:'; + } + + @override + String get actionSheetReadReceiptsZeroReadCount => + 'No one has read this message yet.'; + + @override + String get actionSheetReadReceiptsErrorReadCount => + 'Failed to load read receipts.'; + @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 499e10db87..1f3a7fe535 100644 --- a/lib/generated/l10n/zulip_localizations_de.dart +++ b/lib/generated/l10n/zulip_localizations_de.dart @@ -151,6 +151,31 @@ class ZulipLocalizationsDe extends ZulipLocalizations { return 'Votes for $emojiName ($num)'; } + @override + String get actionSheetOptionViewReadReceipts => 'View read receipts'; + + @override + String get actionSheetReadReceipts => 'Read receipts'; + + @override + String actionSheetReadReceiptsReadCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'people', + one: 'person', + ); + return 'This message has been read by $count $_temp0:'; + } + + @override + String get actionSheetReadReceiptsZeroReadCount => + 'No one has read this message yet.'; + + @override + String get actionSheetReadReceiptsErrorReadCount => + 'Failed to load read receipts.'; + @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 5dc5a83ddb..96cac985dd 100644 --- a/lib/generated/l10n/zulip_localizations_en.dart +++ b/lib/generated/l10n/zulip_localizations_en.dart @@ -148,6 +148,31 @@ class ZulipLocalizationsEn extends ZulipLocalizations { return 'Votes for $emojiName ($num)'; } + @override + String get actionSheetOptionViewReadReceipts => 'View read receipts'; + + @override + String get actionSheetReadReceipts => 'Read receipts'; + + @override + String actionSheetReadReceiptsReadCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'people', + one: 'person', + ); + return 'This message has been read by $count $_temp0:'; + } + + @override + String get actionSheetReadReceiptsZeroReadCount => + 'No one has read this message yet.'; + + @override + String get actionSheetReadReceiptsErrorReadCount => + 'Failed to load read receipts.'; + @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 88500755e3..d77c3c5d0c 100644 --- a/lib/generated/l10n/zulip_localizations_fr.dart +++ b/lib/generated/l10n/zulip_localizations_fr.dart @@ -148,6 +148,31 @@ class ZulipLocalizationsFr extends ZulipLocalizations { return 'Votes for $emojiName ($num)'; } + @override + String get actionSheetOptionViewReadReceipts => 'View read receipts'; + + @override + String get actionSheetReadReceipts => 'Read receipts'; + + @override + String actionSheetReadReceiptsReadCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'people', + one: 'person', + ); + return 'This message has been read by $count $_temp0:'; + } + + @override + String get actionSheetReadReceiptsZeroReadCount => + 'No one has read this message yet.'; + + @override + String get actionSheetReadReceiptsErrorReadCount => + 'Failed to load read receipts.'; + @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 ca77f82762..206cdec5e7 100644 --- a/lib/generated/l10n/zulip_localizations_it.dart +++ b/lib/generated/l10n/zulip_localizations_it.dart @@ -150,6 +150,31 @@ class ZulipLocalizationsIt extends ZulipLocalizations { return 'Votes for $emojiName ($num)'; } + @override + String get actionSheetOptionViewReadReceipts => 'View read receipts'; + + @override + String get actionSheetReadReceipts => 'Read receipts'; + + @override + String actionSheetReadReceiptsReadCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'people', + one: 'person', + ); + return 'This message has been read by $count $_temp0:'; + } + + @override + String get actionSheetReadReceiptsZeroReadCount => + 'No one has read this message yet.'; + + @override + String get actionSheetReadReceiptsErrorReadCount => + 'Failed to load read receipts.'; + @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 bc2dbaa02b..3b00c559c1 100644 --- a/lib/generated/l10n/zulip_localizations_ja.dart +++ b/lib/generated/l10n/zulip_localizations_ja.dart @@ -146,6 +146,31 @@ class ZulipLocalizationsJa extends ZulipLocalizations { return 'Votes for $emojiName ($num)'; } + @override + String get actionSheetOptionViewReadReceipts => 'View read receipts'; + + @override + String get actionSheetReadReceipts => 'Read receipts'; + + @override + String actionSheetReadReceiptsReadCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'people', + one: 'person', + ); + return 'This message has been read by $count $_temp0:'; + } + + @override + String get actionSheetReadReceiptsZeroReadCount => + 'No one has read this message yet.'; + + @override + String get actionSheetReadReceiptsErrorReadCount => + 'Failed to load read receipts.'; + @override String get actionSheetOptionCopyMessageText => 'メッセージ本文をコピー'; diff --git a/lib/generated/l10n/zulip_localizations_nb.dart b/lib/generated/l10n/zulip_localizations_nb.dart index 347f9ad5b5..19c81eb753 100644 --- a/lib/generated/l10n/zulip_localizations_nb.dart +++ b/lib/generated/l10n/zulip_localizations_nb.dart @@ -148,6 +148,31 @@ class ZulipLocalizationsNb extends ZulipLocalizations { return 'Votes for $emojiName ($num)'; } + @override + String get actionSheetOptionViewReadReceipts => 'View read receipts'; + + @override + String get actionSheetReadReceipts => 'Read receipts'; + + @override + String actionSheetReadReceiptsReadCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'people', + one: 'person', + ); + return 'This message has been read by $count $_temp0:'; + } + + @override + String get actionSheetReadReceiptsZeroReadCount => + 'No one has read this message yet.'; + + @override + String get actionSheetReadReceiptsErrorReadCount => + 'Failed to load read receipts.'; + @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 750ec80f75..b475f0453d 100644 --- a/lib/generated/l10n/zulip_localizations_pl.dart +++ b/lib/generated/l10n/zulip_localizations_pl.dart @@ -151,6 +151,31 @@ class ZulipLocalizationsPl extends ZulipLocalizations { return 'Votes for $emojiName ($num)'; } + @override + String get actionSheetOptionViewReadReceipts => 'View read receipts'; + + @override + String get actionSheetReadReceipts => 'Read receipts'; + + @override + String actionSheetReadReceiptsReadCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'people', + one: 'person', + ); + return 'This message has been read by $count $_temp0:'; + } + + @override + String get actionSheetReadReceiptsZeroReadCount => + 'No one has read this message yet.'; + + @override + String get actionSheetReadReceiptsErrorReadCount => + 'Failed to load read receipts.'; + @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 5440b945c3..dc395e7629 100644 --- a/lib/generated/l10n/zulip_localizations_ru.dart +++ b/lib/generated/l10n/zulip_localizations_ru.dart @@ -151,6 +151,31 @@ class ZulipLocalizationsRu extends ZulipLocalizations { return 'Votes for $emojiName ($num)'; } + @override + String get actionSheetOptionViewReadReceipts => 'View read receipts'; + + @override + String get actionSheetReadReceipts => 'Read receipts'; + + @override + String actionSheetReadReceiptsReadCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'people', + one: 'person', + ); + return 'This message has been read by $count $_temp0:'; + } + + @override + String get actionSheetReadReceiptsZeroReadCount => + 'No one has read this message yet.'; + + @override + String get actionSheetReadReceiptsErrorReadCount => + 'Failed to load read receipts.'; + @override String get actionSheetOptionCopyMessageText => 'Скопировать текст сообщения'; diff --git a/lib/generated/l10n/zulip_localizations_sk.dart b/lib/generated/l10n/zulip_localizations_sk.dart index 667a3fa37c..8ccfdb0e7e 100644 --- a/lib/generated/l10n/zulip_localizations_sk.dart +++ b/lib/generated/l10n/zulip_localizations_sk.dart @@ -148,6 +148,31 @@ class ZulipLocalizationsSk extends ZulipLocalizations { return 'Votes for $emojiName ($num)'; } + @override + String get actionSheetOptionViewReadReceipts => 'View read receipts'; + + @override + String get actionSheetReadReceipts => 'Read receipts'; + + @override + String actionSheetReadReceiptsReadCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'people', + one: 'person', + ); + return 'This message has been read by $count $_temp0:'; + } + + @override + String get actionSheetReadReceiptsZeroReadCount => + 'No one has read this message yet.'; + + @override + String get actionSheetReadReceiptsErrorReadCount => + 'Failed to load read receipts.'; + @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 d1aad71ef9..a4176c45b3 100644 --- a/lib/generated/l10n/zulip_localizations_sl.dart +++ b/lib/generated/l10n/zulip_localizations_sl.dart @@ -149,6 +149,31 @@ class ZulipLocalizationsSl extends ZulipLocalizations { return 'Votes for $emojiName ($num)'; } + @override + String get actionSheetOptionViewReadReceipts => 'View read receipts'; + + @override + String get actionSheetReadReceipts => 'Read receipts'; + + @override + String actionSheetReadReceiptsReadCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'people', + one: 'person', + ); + return 'This message has been read by $count $_temp0:'; + } + + @override + String get actionSheetReadReceiptsZeroReadCount => + 'No one has read this message yet.'; + + @override + String get actionSheetReadReceiptsErrorReadCount => + 'Failed to load read receipts.'; + @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 9ef1000ed9..37feb137fe 100644 --- a/lib/generated/l10n/zulip_localizations_uk.dart +++ b/lib/generated/l10n/zulip_localizations_uk.dart @@ -152,6 +152,31 @@ class ZulipLocalizationsUk extends ZulipLocalizations { return 'Votes for $emojiName ($num)'; } + @override + String get actionSheetOptionViewReadReceipts => 'View read receipts'; + + @override + String get actionSheetReadReceipts => 'Read receipts'; + + @override + String actionSheetReadReceiptsReadCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'people', + one: 'person', + ); + return 'This message has been read by $count $_temp0:'; + } + + @override + String get actionSheetReadReceiptsZeroReadCount => + 'No one has read this message yet.'; + + @override + String get actionSheetReadReceiptsErrorReadCount => + 'Failed to load read receipts.'; + @override String get actionSheetOptionCopyMessageText => 'Копіювати текст повідомлення'; diff --git a/lib/generated/l10n/zulip_localizations_zh.dart b/lib/generated/l10n/zulip_localizations_zh.dart index 65fdc05242..aa84764307 100644 --- a/lib/generated/l10n/zulip_localizations_zh.dart +++ b/lib/generated/l10n/zulip_localizations_zh.dart @@ -148,6 +148,31 @@ class ZulipLocalizationsZh extends ZulipLocalizations { return 'Votes for $emojiName ($num)'; } + @override + String get actionSheetOptionViewReadReceipts => 'View read receipts'; + + @override + String get actionSheetReadReceipts => 'Read receipts'; + + @override + String actionSheetReadReceiptsReadCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'people', + one: 'person', + ); + return 'This message has been read by $count $_temp0:'; + } + + @override + String get actionSheetReadReceiptsZeroReadCount => + 'No one has read this message yet.'; + + @override + String get actionSheetReadReceiptsErrorReadCount => + 'Failed to load read receipts.'; + @override String get actionSheetOptionCopyMessageText => 'Copy message text'; diff --git a/lib/widgets/action_sheet.dart b/lib/widgets/action_sheet.dart index 21f29ee81c..3f76b034a6 100644 --- a/lib/widgets/action_sheet.dart +++ b/lib/widgets/action_sheet.dart @@ -29,6 +29,7 @@ import 'icons.dart'; import 'inset_shadow.dart'; import 'message_list.dart'; import 'page.dart'; +import 'read_receipts.dart'; import 'store.dart'; import 'text.dart'; import 'theme.dart'; @@ -720,6 +721,7 @@ void showMessageActionSheet({required BuildContext context, required Message mes ReactionButtons(message: message, pageContext: pageContext), if (hasReactions) ViewReactionsButton(message: message, pageContext: pageContext), + ViewReadReceiptsButton(message: message, pageContext: pageContext), StarButton(message: message, pageContext: pageContext), if (isComposeBoxOffered) QuoteAndReplyButton(message: message, pageContext: pageContext), @@ -969,6 +971,21 @@ class ViewReactionsButton extends MessageActionSheetMenuItemButton { } } +class ViewReadReceiptsButton extends MessageActionSheetMenuItemButton { + ViewReadReceiptsButton({super.key, required super.message, required super.pageContext}); + + @override IconData get icon => ZulipIcons.check_check; + + @override + String label(ZulipLocalizations zulipLocalizations) { + return zulipLocalizations.actionSheetOptionViewReadReceipts; + } + + @override void onPressed() { + showReadReceiptsSheet(pageContext, messageId: message.id); + } +} + class StarButton extends MessageActionSheetMenuItemButton { StarButton({super.key, required super.message, required super.pageContext}); diff --git a/lib/widgets/read_receipts.dart b/lib/widgets/read_receipts.dart new file mode 100644 index 0000000000..99c1f11f0b --- /dev/null +++ b/lib/widgets/read_receipts.dart @@ -0,0 +1,215 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:styled_text/styled_text.dart'; + +import '../api/route/messages.dart'; +import '../generated/l10n/zulip_localizations.dart'; +import 'action_sheet.dart'; +import 'actions.dart'; +import 'color.dart'; +import 'inset_shadow.dart'; +import 'profile.dart'; +import 'store.dart'; +import 'text.dart'; +import 'theme.dart'; +import 'user.dart'; + +/// Opens a bottom sheet showing who has read the message. +void showReadReceiptsSheet(BuildContext pageContext, {required int messageId}) { + 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: ReadReceipts(messageId: messageId))); + }); +} + +class ReadReceipts extends StatefulWidget { + const ReadReceipts({super.key, required this.messageId}); + + final int messageId; + + @override + State createState() => _ReadReceiptsState(); +} + +class _ReadReceiptsState extends State { + List userIds = []; + FetchStatus status = FetchStatus.loading; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + + tryFetchReadReceipts(); + } + + Future tryFetchReadReceipts() async { + final store = PerAccountStoreWidget.of(context); + try { + final result = await getReadReceipts(store.connection, messageId: widget.messageId); + // TODO(i18n): add locale-aware sorting + userIds = result.userIds.sortedByCompare( + (id) => store.userDisplayName(id), + (nameA, nameB) => nameA.toLowerCase().compareTo(nameB.toLowerCase()), + ); + status = FetchStatus.success; + } catch (e) { + status = FetchStatus.error; + } finally { + setState(() {}); + } + } + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 500, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _ReadReceiptsHeader(receiptCount: userIds.length, status: status), + Expanded(child: _ReadReceiptsUserList(userIds: userIds, status: status)), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: const BottomSheetDismissButton(style: BottomSheetDismissButtonStyle.close)) + ])); + } +} + +enum FetchStatus { loading, success, error } + +class _ReadReceiptsHeader extends StatelessWidget { + const _ReadReceiptsHeader({required this.receiptCount, required this.status}); + + final int receiptCount; + final FetchStatus status; + + @override + Widget build(BuildContext context) { + final localizations = ZulipLocalizations.of(context); + final designVariables = DesignVariables.of(context); + + return Padding( + padding: EdgeInsetsDirectional.fromSTEB(18, 16, 18, 8), + child: Column( + spacing: 8, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text(localizations.actionSheetReadReceipts, + style: TextStyle( + fontSize: 20, + height: 20 / 20, + color: designVariables.title, + ).merge(weightVariableTextStyle(context, wght: 600))), + if (status == FetchStatus.success && receiptCount > 0) + StyledText( + text: localizations.actionSheetReadReceiptsReadCount(receiptCount), + tags: { + 'link': StyledTextActionTag((_, attrs) { + PlatformActions.launchUrl(context, Uri.parse(attrs['href']!)); + }, + style: TextStyle( + decoration: TextDecoration.underline, + color: designVariables.link, + decorationColor: designVariables.link), + )}, + style: TextStyle(fontSize: 17, height: 22 / 17, + color: designVariables.textMessage)), + ])); + } +} + +class _ReadReceiptsUserList extends StatelessWidget { + const _ReadReceiptsUserList({required this.userIds, required this.status}); + + final List userIds; + final FetchStatus status; + + @override + Widget build(BuildContext context) { + final localizations = ZulipLocalizations.of(context); + final designVariables = DesignVariables.of(context); + + return Center( + child: switch(status) { + FetchStatus.loading => CircularProgressIndicator(), + FetchStatus.error => BottomSheetInfoText( + text: localizations.actionSheetReadReceiptsErrorReadCount, + textAlign: TextAlign.center), + FetchStatus.success => userIds.isEmpty + ? BottomSheetInfoText( + text: localizations.actionSheetReadReceiptsZeroReadCount, + textAlign: TextAlign.center) + : InsetShadowBox( + top: 8, bottom: 8, + color: designVariables.bgContextMenu, + child: ListView.builder( + padding: EdgeInsets.symmetric(vertical: 8), + itemCount: userIds.length, + itemBuilder: (context, index) => + ReadReceiptsUserItem(userId: userIds[index]))) + }); + } +} + + +// TODO: deduplicate the code with [ViewReactionsUserItem] +@visibleForTesting +class ReadReceiptsUserItem extends StatelessWidget { + const ReadReceiptsUserItem({super.key, required this.userId}); + + final int userId; + + void _onPressed(BuildContext context) { + // Dismiss the action sheet. + Navigator.pop(context); + + Navigator.push(context, + ProfilePage.buildRoute(context: context, userId: userId)); + } + + @override + Widget build(BuildContext context) { + final store = PerAccountStoreWidget.of(context); + final designVariables = DesignVariables.of(context); + + return InkWell( + onTap: () => _onPressed(context), + 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/lib/widgets/theme.dart b/lib/widgets/theme.dart index e66a9fc535..b059d24427 100644 --- a/lib/widgets/theme.dart +++ b/lib/widgets/theme.dart @@ -172,6 +172,7 @@ class DesignVariables extends ThemeExtension { labelMenuButton: const Color(0xff222222), labelSearchPrompt: const Color(0xff000000).withValues(alpha: 0.5), labelTime: const Color(0x00000000).withValues(alpha: 0.49), + link: const Color(0xff066bd0), listMenuItemBg: const Color(0xffcbcdd6), listMenuItemIcon: const Color(0xff9194a3), listMenuItemText: const Color(0xff2d303c), @@ -262,6 +263,7 @@ class DesignVariables extends ThemeExtension { labelMenuButton: const Color(0xffffffff).withValues(alpha: 0.85), labelSearchPrompt: const Color(0xffffffff).withValues(alpha: 0.5), labelTime: const Color(0xffffffff).withValues(alpha: 0.50), + link: const Color(0xff066bd0), listMenuItemBg: const Color(0xff2d303c), listMenuItemIcon: const Color(0xff767988), listMenuItemText: const Color(0xffcbcdd6), @@ -361,6 +363,7 @@ class DesignVariables extends ThemeExtension { required this.labelMenuButton, required this.labelSearchPrompt, required this.labelTime, + required this.link, required this.listMenuItemBg, required this.listMenuItemIcon, required this.listMenuItemText, @@ -451,6 +454,7 @@ class DesignVariables extends ThemeExtension { final Color labelMenuButton; final Color labelSearchPrompt; final Color labelTime; + final Color link; final Color listMenuItemBg; final Color listMenuItemIcon; final Color listMenuItemText; @@ -536,6 +540,7 @@ class DesignVariables extends ThemeExtension { Color? labelMenuButton, Color? labelSearchPrompt, Color? labelTime, + Color? link, Color? listMenuItemBg, Color? listMenuItemIcon, Color? listMenuItemText, @@ -616,6 +621,7 @@ class DesignVariables extends ThemeExtension { labelMenuButton: labelMenuButton ?? this.labelMenuButton, labelSearchPrompt: labelSearchPrompt ?? this.labelSearchPrompt, labelTime: labelTime ?? this.labelTime, + link: link ?? this.link, listMenuItemBg: listMenuItemBg ?? this.listMenuItemBg, listMenuItemIcon: listMenuItemIcon ?? this.listMenuItemIcon, listMenuItemText: listMenuItemText ?? this.listMenuItemText, @@ -703,6 +709,7 @@ class DesignVariables extends ThemeExtension { labelMenuButton: Color.lerp(labelMenuButton, other.labelMenuButton, t)!, labelSearchPrompt: Color.lerp(labelSearchPrompt, other.labelSearchPrompt, t)!, labelTime: Color.lerp(labelTime, other.labelTime, t)!, + link: Color.lerp(link, other.link, t)!, listMenuItemBg: Color.lerp(listMenuItemBg, other.listMenuItemBg, t)!, listMenuItemIcon: Color.lerp(listMenuItemIcon, other.listMenuItemIcon, t)!, listMenuItemText: Color.lerp(listMenuItemText, other.listMenuItemText, t)!, diff --git a/test/widgets/action_sheet_test.dart b/test/widgets/action_sheet_test.dart index d65ac20507..1fa84f6d4a 100644 --- a/test/widgets/action_sheet_test.dart +++ b/test/widgets/action_sheet_test.dart @@ -32,6 +32,7 @@ import 'package:zulip/widgets/icons.dart'; import 'package:zulip/widgets/inbox.dart'; import 'package:zulip/widgets/message_list.dart'; import 'package:share_plus_platform_interface/method_channel/method_channel_share.dart'; +import 'package:zulip/widgets/read_receipts.dart'; import 'package:zulip/widgets/subscription_list.dart'; import 'package:zulip/widgets/user.dart'; import '../api/fake_api.dart'; @@ -1155,6 +1156,43 @@ void main() { }); }); + group('ViewReadReceiptsButton', () { + final findButtonInSheet = find.descendant( + of: find.byType(BottomSheet), + matching: find.byIcon(ZulipIcons.check_check)); + + Future tapButton(WidgetTester tester) async { + await tester.ensureVisible(findButtonInSheet); + await tester.tap(findButtonInSheet); + await tester.pump(); // [MenuItemButton.onPressed] called in a post-frame callback: flutter/flutter@e4a39fa2e + } + + testWidgets('smoke', (tester) async { + await setupToMessageActionSheet(tester, + message: eg.streamMessage(), 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); + + // message action sheet exited + check(find.ancestor(of: find.byIcon(ZulipIcons.check_check), + matching: find.byType(BottomSheet))).findsNothing(); + + // receipts sheet opened + check(find.ancestor(of: find.byType(ReadReceipts), + matching: find.byType(BottomSheet))).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/read_receipts_test.dart b/test/widgets/read_receipts_test.dart new file mode 100644 index 0000000000..8615797c07 --- /dev/null +++ b/test/widgets/read_receipts_test.dart @@ -0,0 +1,179 @@ +import 'dart:io'; + +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:http/http.dart' as http; +import 'package:zulip/api/model/model.dart'; +import 'package:zulip/api/route/messages.dart'; +import 'package:zulip/model/narrow.dart'; +import 'package:zulip/model/store.dart'; +import 'package:zulip/widgets/content.dart'; +import 'package:zulip/widgets/icons.dart'; +import 'package:zulip/widgets/message_list.dart'; +import 'package:zulip/widgets/profile.dart'; +import 'package:zulip/widgets/read_receipts.dart'; + +import '../api/fake_api.dart'; +import '../example_data.dart' as eg; +import '../model/binding.dart'; +import '../model/test_store.dart'; +import '../stdlib_checks.dart'; +import 'test_app.dart'; + +void main() { + TestZulipBinding.ensureInitialized(); + + late PerAccountStore store; + late FakeApiConnection connection; + late TransitionDurationObserver transitionDurationObserver; + + Future setupReceiptsSheet(WidgetTester tester, { + required int messageId, + required List users, + required VoidCallback prepareReceiptsResponse, + }) async { + addTearDown(testBinding.reset); + + await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); + store = await testBinding.globalStore.perAccount(eg.selfAccount.id); + await store.addUsers(users); + + final message = eg.streamMessage(id: messageId); + final stream = eg.stream(streamId: message.streamId); + await store.addStream(stream); + await store.addSubscription(eg.subscription(stream)); + + connection = store.connection as FakeApiConnection; + connection.prepare(json: eg.newestGetMessagesResult( + foundOldest: true, messages: [message]).toJson()); + + transitionDurationObserver = TransitionDurationObserver(); + await tester.pumpWidget(TestZulipApp(accountId: eg.selfAccount.id, + navigatorObservers: [transitionDurationObserver], + child: MessageListPage(initNarrow: CombinedFeedNarrow()))); + // global store, per-account store, and message list get loaded + await tester.pumpAndSettle(); + + await tester.longPress(find.byType(MessageContent)); + await transitionDurationObserver.pumpPastTransition(tester); + + prepareReceiptsResponse(); + await tester.tap(find.byIcon(ZulipIcons.check_check)); + await transitionDurationObserver.pumpPastTransition(tester); + + check(find.ancestor(of: find.byType(ReadReceipts), + matching: find.byType(BottomSheet))).findsOne(); // receipts sheet opened + check(find.byType(CircularProgressIndicator)).findsOne(); + check(find.text('Read receipts')).findsOne(); + check(find.text('Close')).findsOne(); + + await tester.pumpAndSettle(); + } + + Finder findUserItem(String fullName) => find.ancestor(of: find.text(fullName), + matching: find.byType(ReadReceiptsUserItem)); + + group('success', () { + testWidgets('message read by many people', (tester) async { + final user1 = eg.user(userId: 1, fullName: 'User 1'); + final user2 = eg.user(userId: 2, fullName: 'User 2'); + await setupReceiptsSheet(tester, messageId: 100, users: [user1, user2], + prepareReceiptsResponse: () { + connection.prepare( + json: GetReadReceiptsResult(userIds: [1, 2]).toJson(), + delay: transitionDurationObserver.transitionDuration + + const Duration(milliseconds: 100)); + }); + + check(connection.lastRequest).isA() + ..method.equals('GET') + ..url.path.equals('/api/v1/messages/100/read_receipts'); + + check(find.text('This message has been read by 2 people:', + findRichText: true)).findsOne(); + check(findUserItem('User 1')).findsOne(); + check(findUserItem('User 2')).findsOne(); + }); + + testWidgets('message read by one person', (tester) async { + final user1 = eg.user(userId: 1, fullName: 'User 1'); + final user2 = eg.user(userId: 2, fullName: 'User 2'); + await setupReceiptsSheet(tester, messageId: 100, users: [user1, user2], + prepareReceiptsResponse: () { + connection.prepare( + json: GetReadReceiptsResult(userIds: [1]).toJson(), + delay: transitionDurationObserver.transitionDuration + + const Duration(milliseconds: 100)); + }); + + check(connection.lastRequest).isA() + ..method.equals('GET') + ..url.path.equals('/api/v1/messages/100/read_receipts'); + + check(find.text('This message has been read by 1 person:', + findRichText: true)).findsOne(); + check(findUserItem('User 1')).findsOne(); + check(findUserItem('User 2')).findsNothing(); + }); + + testWidgets('message read by no one', (tester) async { + final user1 = eg.user(userId: 1, fullName: 'User 1'); + final user2 = eg.user(userId: 2, fullName: 'User 2'); + await setupReceiptsSheet(tester, messageId: 100, users: [user1, user2], + prepareReceiptsResponse: () { + connection.prepare( + json: GetReadReceiptsResult(userIds: []).toJson(), + delay: transitionDurationObserver.transitionDuration + + const Duration(milliseconds: 100)); + }); + + check(connection.lastRequest).isA() + ..method.equals('GET') + ..url.path.equals('/api/v1/messages/100/read_receipts'); + + check(find.text('No one has read this message yet.')).findsOne(); + check(findUserItem('User 1')).findsNothing(); + check(findUserItem('User 2')).findsNothing(); + }); + + testWidgets('tapping user item opens their profile', (tester) async { + final user = eg.user(userId: 1, fullName: 'User 1'); + await setupReceiptsSheet(tester, messageId: 100, users: [user], + prepareReceiptsResponse: () { + connection.prepare( + json: GetReadReceiptsResult(userIds: [1]).toJson(), + delay: transitionDurationObserver.transitionDuration + + const Duration(milliseconds: 100)); + }); + + check(connection.lastRequest).isA() + ..method.equals('GET') + ..url.path.equals('/api/v1/messages/100/read_receipts'); + + await tester.tap(findUserItem('User 1')); + await transitionDurationObserver.pumpPastTransition(tester); + check(find.byWidgetPredicate((widget) => switch(widget) { + ProfilePage(userId: 1) => true, + _ => false, + })).findsOne(); + }); + }); + + testWidgets('failure', (tester) async { + await setupReceiptsSheet(tester, messageId: 100, users: [], + prepareReceiptsResponse: () { + connection.prepare( + httpException: SocketException('failed'), + delay: transitionDurationObserver.transitionDuration + + const Duration(milliseconds: 100)); + }); + + check(connection.lastRequest).isA() + ..method.equals('GET') + ..url.path.equals('/api/v1/messages/100/read_receipts'); + + check(find.text('Failed to load read receipts.')).findsOne(); + }); +}