From e1f5235e92073aafd58737e3642d9da9f0cbb786 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Wed, 9 Jul 2025 14:01:42 -0700 Subject: [PATCH 1/8] action_sheet: Choose "Close" and "Cancel" consistently for bottom sheets This fixes the two inconsistencies flagged in discussion: https://chat.zulip.org/#narrow/channel/530-mobile-design/topic/bottom.20sheet.20.22Cancel.22.2F.22Close.22.20button/near/2216116 > I think it's reasonable to have both labels, but I think we should > choose them differently than now: > > - "Cancel" when the sheet is about doing an action: [etc.] > > - "Close" when the sheet just presents information or nav options: > [etc.] --- lib/widgets/action_sheet.dart | 26 ++++++++++++++++++++++---- lib/widgets/emoji_reaction.dart | 2 +- lib/widgets/home.dart | 3 ++- test/widgets/home_test.dart | 6 +++--- 4 files changed, 28 insertions(+), 9 deletions(-) diff --git a/lib/widgets/action_sheet.dart b/lib/widgets/action_sheet.dart index 6b280df6ee..deec4b5952 100644 --- a/lib/widgets/action_sheet.dart +++ b/lib/widgets/action_sheet.dart @@ -92,7 +92,7 @@ void _showActionSheet( child: SingleChildScrollView( padding: const EdgeInsets.symmetric(vertical: 8), child: MenuButtonsShape(buttons: optionButtons)))), - const ActionSheetCancelButton(), + const BottomSheetDismissButton(style: BottomSheetDismissButtonStyle.cancel), ]))), ])))); }); @@ -160,12 +160,22 @@ abstract class ActionSheetMenuItemButton extends StatelessWidget { } } -class ActionSheetCancelButton extends StatelessWidget { - const ActionSheetCancelButton({super.key}); +/// A stretched gray "Cancel" / "Close" button for the bottom of a bottom sheet. +class BottomSheetDismissButton extends StatelessWidget { + const BottomSheetDismissButton({super.key, required this.style}); + + final BottomSheetDismissButtonStyle style; @override Widget build(BuildContext context) { final designVariables = DesignVariables.of(context); + final zulipLocalizations = ZulipLocalizations.of(context); + + final label = switch (style) { + BottomSheetDismissButtonStyle.cancel => zulipLocalizations.dialogCancel, + BottomSheetDismissButtonStyle.close => zulipLocalizations.dialogClose, + }; + return TextButton( style: TextButton.styleFrom( minimumSize: const Size.fromHeight(44), @@ -180,12 +190,20 @@ class ActionSheetCancelButton extends StatelessWidget { onPressed: () { Navigator.pop(context); }, - child: Text(ZulipLocalizations.of(context).dialogCancel, + child: Text(label, style: const TextStyle(fontSize: 20, height: 24 / 20) .merge(weightVariableTextStyle(context, wght: 600)))); } } +enum BottomSheetDismissButtonStyle { + /// The "Cancel" label, for action sheets. + cancel, + + /// The "Close" label, for bottom sheets that are read-only or for navigation. + close, +} + /// Show a sheet of actions you can take on a channel. /// /// Needs a [PageRoot] ancestor. diff --git a/lib/widgets/emoji_reaction.dart b/lib/widgets/emoji_reaction.dart index 3c26361d3a..bc22b4d4a5 100644 --- a/lib/widgets/emoji_reaction.dart +++ b/lib/widgets/emoji_reaction.dart @@ -518,7 +518,7 @@ class _EmojiPickerState extends State with PerAccountStoreAwareStat states.contains(WidgetState.pressed) ? designVariables.contextMenuItemBg.withFadedAlpha(0.20) : Colors.transparent)), - child: Text(zulipLocalizations.dialogClose, + child: Text(zulipLocalizations.dialogCancel, style: const TextStyle(fontSize: 20, height: 30 / 20))), ])), Expanded(child: InsetShadowBox( diff --git a/lib/widgets/home.dart b/lib/widgets/home.dart index dd269e03f7..c57fca7b22 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 1b5c8ad8b5..619cf984b3 100644 --- a/test/widgets/home_test.dart +++ b/test/widgets/home_test.dart @@ -231,7 +231,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(); @@ -265,10 +265,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 b1daffcabf68411f3c58caf21c6d9cf1eb44340d Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Wed, 9 Jul 2025 15:55:23 -0700 Subject: [PATCH 2/8] icons: Add see_who_reacted, from Vlad on CZO See Vlad's SVG on CZO: https://chat.zulip.org/#narrow/channel/530-mobile-design/topic/View.20Reactions.20ModalBottomSheet/near/2025350 --- assets/icons/ZulipIcons.ttf | Bin 16076 -> 16528 bytes assets/icons/see_who_reacted.svg | 6 ++++++ lib/widgets/icons.dart | 27 +++++++++++++++------------ 3 files changed, 21 insertions(+), 12 deletions(-) create mode 100644 assets/icons/see_who_reacted.svg diff --git a/assets/icons/ZulipIcons.ttf b/assets/icons/ZulipIcons.ttf index 85f393019a00470c3f77ab60b05ae32b1863547b..355d1e8dca9a8f48bc4f52dd6939855dba942a45 100644 GIT binary patch delta 2391 zcmb7GOH3SP9RGi_Pxb}7%r0zOXxUl80%c*?_Y2tN4W&tIIW+}@1*)ZBi+yO#)=Tx! zgYCwoYNClT)mRT&+UlVvvVHUbNPv2QN)L7!%One|C_XcyWi{{J-z}zaKNh z+G}sUE{Y_G$WG_TK*_Vg{sctlabOk3F|2O^!TE*d({~>JwGMle z?BnR-bY?1iXZIBt7=*z43sa|;#H@&dS&RLyV^a$S?^oR_QR)Jb6kA$6u^j$Zx=EB- z#eVJLEyX#eWq>Sxe8-==_*;h8K!?a8iRZ$dIjN+7_G-W7DU06k_n|dgRwE;!ilGr*o;k*l1gnA*DfL=H2 zb6ssuDI_)1DKe5pGo059r{LI30eXgj~ z=@dd61nt3jJuJtdH-JE`kSGSo16LzpCBVqys)9%oX1mA@A?C2LT_mFpF@)L=Q=IcC zjK;9aP&N25WRu1{4s9DMngq8U8aZfWH0#5-M-gZY0d+zqj#Z+F=(mnl&CoEM_d>7( zrH_HnJ2tuvIjXb+;ih1gwKzyWq&OtbZX4Dq>Vwu^+76u*(uwJ{@F8Dr+#oC_5MDod zAzkE{`=H7>)>93-6oG5*5F3vmCw5;;75|)Xwxj|T38M;=bR3Vx1-eWh(zBSRa<*~U zKz>^G+;gr|l0w=PL~yE4K#_+4j8-_v!37tKUB?haGa}AFu?u_dVV;^0B=tx-!N3hOdtkJ6h6@;ZG@cjyOH)JH`wB#%P^_ioKF2b4gW zQks)8mqRR@_-|~XiBem?BlOl(?-79qAdvo7Sz;^l{gXWD{%Bj9td zBj9qrWmPBE+&T`u5?S(`jNn-^Lc@Sv7gjb@u$RU#y4;jJuJV*{s+)F-oLK{6`nNz9 zd*3P9l^nW+sQG4j_tR0%)BXMD$RFEF7a0Xn540{Xm;KG10$y`VEXlAr}0 z8PHiBS$@6bsdL!hU0;Dbn~b#R5w=oneKW}B64Pr^hD3YTh~`^T!< zs}8w;_nh_It)8r2l_h!2TkE~x3;OQ;|BGnly7TAyq7~N3@W5~tPvH)25Dp`qvveL` z%MR5io0X10n+Rt`O*onoYQ2zMqD2H6YrGb-#jFIH8bhKnD||bJug0gwqOp#ws27&T zc99jG4UqS`J5^sDt0@hEHYws(0*x6Fj+z>k2A{X+mg|L@6@1d!;0_C~Y-v!V86nJd zqB9b16J~MMmusj7OYBGemEPY?-I1Q)I>g71xgH|4+qkG^^T{QrC3TW5Uf zLv8PU8ybKbd;k}Ea_3%3Us#&|4$z{2d+UXB-F+|LeX$m(`kq>^Pc9Z$Ub*%8r+{~r zd)Hp_c+(PM919X>iV67zyAU}N%{ya zEKkOZ!5hc-NYFxs&BfySiZ-noJe21XOU1?MrW?sVAhr+ak(K3(tFs3a9|Kk$pTD`h z+wLECGGG>Mw|~BU`PW`;yws-6+9m?%z*pGE?^=g;R@>4JbdMg@Z@RWz+ist`6;-H3 z7l!c~R-vN~VO|Czh++U1;z)26#vqbNaqVJ}4mA$=WN10cB=_6M zY|w6)fmTz)K7|IlD)1=EgE+4`s>Dc}Ks}Y@M95Ac$T}DdHA^bVd6c9ER~8z1ZZMkx z-Xpa3v7#o!=%z)A7M8=F=RM3o4W9K;C&E?KX7q>Ky$(^H&WEVj!_pf(mnR`T${hR9 z%5Y86ij@TU7_}rM$?hoECNi`d!!g>JOvg~QXlB0BxEvXyOfZWkYTFWXhNhBZ8y;ns zLUb)167mpp689}Q@?gKxl4@4&8S0$jWL&@+UyUxO$053@^j9V%hG%J=qP1I)5*~p7(B(iELz0y99I%znDjx~FW?hg!*$%m zx46f8HPc>ZWSCBe=tX!XpeSo#IzBAeax5_l#ebWjnnap zQ^~h7kdH85(JsFMGO%8z8?vb&oec3<@-3VCxt7LAZ~Jyv}1D8^86J?s?mLqx7GlzZm0-bC6PO^)RE$ zG6yN#D9f8>ZaH==!WW}Z>Z+b4y{B~YNbwl0Gea>eo=nkdAZr@=(QrDE8;Oh+c>Z4L zjJL+-haGTTl{ + + + + + diff --git a/lib/widgets/icons.dart b/lib/widgets/icons.dart index 1b5c424b0b..1b8457a9f7 100644 --- a/lib/widgets/icons.dart +++ b/lib/widgets/icons.dart @@ -141,41 +141,44 @@ abstract final class ZulipIcons { /// The Zulip custom icon "search". static const IconData search = IconData(0xf127, fontFamily: "Zulip Icons"); + /// The Zulip custom icon "see_who_reacted". + static const IconData see_who_reacted = IconData(0xf128, fontFamily: "Zulip Icons"); + /// The Zulip custom icon "send". - static const IconData send = IconData(0xf128, fontFamily: "Zulip Icons"); + static const IconData send = IconData(0xf129, fontFamily: "Zulip Icons"); /// The Zulip custom icon "settings". - static const IconData settings = IconData(0xf129, fontFamily: "Zulip Icons"); + static const IconData settings = IconData(0xf12a, fontFamily: "Zulip Icons"); /// The Zulip custom icon "share". - static const IconData share = IconData(0xf12a, fontFamily: "Zulip Icons"); + static const IconData share = IconData(0xf12b, fontFamily: "Zulip Icons"); /// The Zulip custom icon "share_ios". - static const IconData share_ios = IconData(0xf12b, fontFamily: "Zulip Icons"); + static const IconData share_ios = IconData(0xf12c, fontFamily: "Zulip Icons"); /// The Zulip custom icon "smile". - static const IconData smile = IconData(0xf12c, fontFamily: "Zulip Icons"); + static const IconData smile = IconData(0xf12d, fontFamily: "Zulip Icons"); /// The Zulip custom icon "star". - static const IconData star = IconData(0xf12d, fontFamily: "Zulip Icons"); + static const IconData star = IconData(0xf12e, fontFamily: "Zulip Icons"); /// The Zulip custom icon "star_filled". - static const IconData star_filled = IconData(0xf12e, fontFamily: "Zulip Icons"); + static const IconData star_filled = IconData(0xf12f, fontFamily: "Zulip Icons"); /// The Zulip custom icon "three_person". - static const IconData three_person = IconData(0xf12f, fontFamily: "Zulip Icons"); + static const IconData three_person = IconData(0xf130, fontFamily: "Zulip Icons"); /// The Zulip custom icon "topic". - static const IconData topic = IconData(0xf130, fontFamily: "Zulip Icons"); + static const IconData topic = IconData(0xf131, fontFamily: "Zulip Icons"); /// The Zulip custom icon "topics". - static const IconData topics = IconData(0xf131, fontFamily: "Zulip Icons"); + static const IconData topics = IconData(0xf132, fontFamily: "Zulip Icons"); /// The Zulip custom icon "two_person". - static const IconData two_person = IconData(0xf132, fontFamily: "Zulip Icons"); + static const IconData two_person = IconData(0xf133, fontFamily: "Zulip Icons"); /// The Zulip custom icon "unmute". - static const IconData unmute = IconData(0xf133, fontFamily: "Zulip Icons"); + static const IconData unmute = IconData(0xf134, fontFamily: "Zulip Icons"); // END GENERATED ICON DATA } From 693856256a0d51b2d9d4840887d85facdd1a7835 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Wed, 9 Jul 2025 13:54:32 -0700 Subject: [PATCH 3/8] msglist: Support viewing who reacted to a message Fixes #740. --- assets/l10n/app_en.arb | 8 + lib/generated/l10n/zulip_localizations.dart | 12 + .../l10n/zulip_localizations_ar.dart | 6 + .../l10n/zulip_localizations_de.dart | 6 + .../l10n/zulip_localizations_en.dart | 6 + .../l10n/zulip_localizations_it.dart | 6 + .../l10n/zulip_localizations_ja.dart | 6 + .../l10n/zulip_localizations_nb.dart | 6 + .../l10n/zulip_localizations_pl.dart | 6 + .../l10n/zulip_localizations_ru.dart | 6 + .../l10n/zulip_localizations_sk.dart | 6 + .../l10n/zulip_localizations_sl.dart | 6 + .../l10n/zulip_localizations_uk.dart | 6 + .../l10n/zulip_localizations_zh.dart | 6 + lib/widgets/action_sheet.dart | 45 +++ lib/widgets/emoji_reaction.dart | 364 ++++++++++++++++++ lib/widgets/inset_shadow.dart | 18 +- lib/widgets/theme.dart | 7 + 18 files changed, 525 insertions(+), 1 deletion(-) diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index c24f23dce9..549e31ec69 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -136,6 +136,14 @@ "@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)." + }, "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 99b52aced1..4f6766b486 100644 --- a/lib/generated/l10n/zulip_localizations.dart +++ b/lib/generated/l10n/zulip_localizations.dart @@ -333,6 +333,18 @@ 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; + /// Label for copy message text button on action sheet. /// /// In en, this message translates to: diff --git a/lib/generated/l10n/zulip_localizations_ar.dart b/lib/generated/l10n/zulip_localizations_ar.dart index 110b0dbe24..846b3fc7df 100644 --- a/lib/generated/l10n/zulip_localizations_ar.dart +++ b/lib/generated/l10n/zulip_localizations_ar.dart @@ -118,6 +118,12 @@ 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 get actionSheetOptionCopyMessageText => 'Copy message text'; diff --git a/lib/generated/l10n/zulip_localizations_de.dart b/lib/generated/l10n/zulip_localizations_de.dart index f3b1bdad67..f6101e712e 100644 --- a/lib/generated/l10n/zulip_localizations_de.dart +++ b/lib/generated/l10n/zulip_localizations_de.dart @@ -121,6 +121,12 @@ 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 get actionSheetOptionCopyMessageText => 'Nachrichtentext kopieren'; diff --git a/lib/generated/l10n/zulip_localizations_en.dart b/lib/generated/l10n/zulip_localizations_en.dart index f99c386087..ba3579857c 100644 --- a/lib/generated/l10n/zulip_localizations_en.dart +++ b/lib/generated/l10n/zulip_localizations_en.dart @@ -118,6 +118,12 @@ 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 get actionSheetOptionCopyMessageText => 'Copy message text'; diff --git a/lib/generated/l10n/zulip_localizations_it.dart b/lib/generated/l10n/zulip_localizations_it.dart index 2d7d35e23e..b3b5f5acb2 100644 --- a/lib/generated/l10n/zulip_localizations_it.dart +++ b/lib/generated/l10n/zulip_localizations_it.dart @@ -120,6 +120,12 @@ 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 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 ff27eaee8b..da8f0c7eaf 100644 --- a/lib/generated/l10n/zulip_localizations_ja.dart +++ b/lib/generated/l10n/zulip_localizations_ja.dart @@ -118,6 +118,12 @@ class ZulipLocalizationsJa 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 get actionSheetOptionCopyMessageText => 'Copy message text'; diff --git a/lib/generated/l10n/zulip_localizations_nb.dart b/lib/generated/l10n/zulip_localizations_nb.dart index 0568bc0ae7..38793e3633 100644 --- a/lib/generated/l10n/zulip_localizations_nb.dart +++ b/lib/generated/l10n/zulip_localizations_nb.dart @@ -118,6 +118,12 @@ 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 get actionSheetOptionCopyMessageText => 'Copy message text'; diff --git a/lib/generated/l10n/zulip_localizations_pl.dart b/lib/generated/l10n/zulip_localizations_pl.dart index 0e9cf379b6..73324c039b 100644 --- a/lib/generated/l10n/zulip_localizations_pl.dart +++ b/lib/generated/l10n/zulip_localizations_pl.dart @@ -121,6 +121,12 @@ 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 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 1349d79baa..4093a57f9a 100644 --- a/lib/generated/l10n/zulip_localizations_ru.dart +++ b/lib/generated/l10n/zulip_localizations_ru.dart @@ -121,6 +121,12 @@ 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 get actionSheetOptionCopyMessageText => 'Скопировать текст сообщения'; diff --git a/lib/generated/l10n/zulip_localizations_sk.dart b/lib/generated/l10n/zulip_localizations_sk.dart index 33b4465eb6..113e73e7b7 100644 --- a/lib/generated/l10n/zulip_localizations_sk.dart +++ b/lib/generated/l10n/zulip_localizations_sk.dart @@ -118,6 +118,12 @@ 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 get actionSheetOptionCopyMessageText => 'Skopírovať text správy'; diff --git a/lib/generated/l10n/zulip_localizations_sl.dart b/lib/generated/l10n/zulip_localizations_sl.dart index 8d587b9085..ae43ea7853 100644 --- a/lib/generated/l10n/zulip_localizations_sl.dart +++ b/lib/generated/l10n/zulip_localizations_sl.dart @@ -119,6 +119,12 @@ 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 get actionSheetOptionCopyMessageText => 'Kopiraj besedilo sporočila'; diff --git a/lib/generated/l10n/zulip_localizations_uk.dart b/lib/generated/l10n/zulip_localizations_uk.dart index 6799942531..2c130cfc85 100644 --- a/lib/generated/l10n/zulip_localizations_uk.dart +++ b/lib/generated/l10n/zulip_localizations_uk.dart @@ -122,6 +122,12 @@ 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 get actionSheetOptionCopyMessageText => 'Копіювати текст повідомлення'; diff --git a/lib/generated/l10n/zulip_localizations_zh.dart b/lib/generated/l10n/zulip_localizations_zh.dart index b7eaba478f..7d93ad7c11 100644 --- a/lib/generated/l10n/zulip_localizations_zh.dart +++ b/lib/generated/l10n/zulip_localizations_zh.dart @@ -118,6 +118,12 @@ 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 get actionSheetOptionCopyMessageText => 'Copy message text'; diff --git a/lib/widgets/action_sheet.dart b/lib/widgets/action_sheet.dart index deec4b5952..1a490451f7 100644 --- a/lib/widgets/action_sheet.dart +++ b/lib/widgets/action_sheet.dart @@ -98,6 +98,35 @@ 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; @@ -631,6 +660,7 @@ void showMessageActionSheet({required BuildContext context, required Message mes final optionButtons = [ if (popularEmojiLoaded) ReactionButtons(message: message, pageContext: pageContext), + ViewReactionsButton(message: message, pageContext: pageContext), StarButton(message: message, pageContext: pageContext), if (isComposeBoxOffered) QuoteAndReplyButton(message: message, pageContext: pageContext), @@ -857,6 +887,21 @@ class ReactionButtons extends StatelessWidget { } } +class ViewReactionsButton extends MessageActionSheetMenuItemButton { + ViewReactionsButton({super.key, required super.message, required super.pageContext}); + + @override IconData get icon => ZulipIcons.see_who_reacted; + + @override + String label(ZulipLocalizations zulipLocalizations) { + return zulipLocalizations.actionSheetOptionSeeWhoReacted; + } + + @override void onPressed() { + showViewReactionsSheet(pageContext, messageId: message.id); + } +} + class StarButton extends MessageActionSheetMenuItemButton { StarButton({super.key, required super.message, required super.pageContext}); diff --git a/lib/widgets/emoji_reaction.dart b/lib/widgets/emoji_reaction.dart index bc22b4d4a5..b735936a68 100644 --- a/lib/widgets/emoji_reaction.dart +++ b/lib/widgets/emoji_reaction.dart @@ -1,3 +1,4 @@ +import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import '../api/exception.dart'; @@ -6,10 +7,15 @@ 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 'content.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'; @@ -205,6 +211,12 @@ class ReactionChip extends StatelessWidget { customBorder: shape, splashColor: splashColor, highlightColor: highlightColor, + onLongPress: () { + showViewReactionsSheet(PageRoot.contextOf(context), + messageId: messageId, + initialReactionType: reactionType, + initialEmojiCode: emojiCode); + }, onTap: () { (selfVoted ? removeReaction : addReaction).call(store.connection, messageId: messageId, @@ -614,3 +626,355 @@ 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; + + PerAccountStore? store; + + void _setSelection(ReactionWithVotes? selection) { + setState(() { + reactionType = selection?.reactionType; + emojiCode = selection?.emojiCode; + }); + } + + 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() { + 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 + 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( + crossAxisAlignment: CrossAxisAlignment.stretch, + 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!)), + 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 reactionsWithVotes = message?.reactions?.aggregated; + + if (reactionsWithVotes == null || reactionsWithVotes.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( + scrollDirection: Axis.horizontal, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Row( + children: reactionsWithVotes.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; + + @override + Widget build(BuildContext context) { + final designVariables = DesignVariables.of(context); + final store = PerAccountStoreWidget.of(context); + final count = reactionWithVotes.userIds.length; + + final emojiDisplay = store.emojiDisplayFor( + emojiType: reactionWithVotes.reactionType, + emojiCode: reactionWithVotes.emojiCode, + emojiName: reactionWithVotes.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, + }; + + return Tooltip( + message: reactionWithVotes.emojiName, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onRequestSelect, + 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? + ]))))); + } +} + + +@visibleForTesting +class ViewReactionsUserList extends StatelessWidget { + const ViewReactionsUserList(this.pageContext, { + super.key, + required this.messageId, + required this.reactionType, + required this.emojiCode, + }); + + final BuildContext pageContext; + final int messageId; + final ReactionType reactionType; + final String emojiCode; + + @override + Widget build(BuildContext 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? + + return 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])))); + } +} + +@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/lib/widgets/inset_shadow.dart b/lib/widgets/inset_shadow.dart index a4133ac7de..c5da27a934 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; @@ -54,6 +66,10 @@ class InsetShadowBox extends StatelessWidget { child: DecoratedBox(decoration: _shadowFrom(Alignment.topCenter))), Positioned(bottom: 0, height: bottom, left: 0, right: 0, child: DecoratedBox(decoration: _shadowFrom(Alignment.bottomCenter))), + PositionedDirectional(start: 0, width: start, top: 0, bottom: 0, + child: DecoratedBox(decoration: _shadowFrom(AlignmentDirectional.centerStart))), + PositionedDirectional(end: 0, width: end, top: 0, bottom: 0, + child: DecoratedBox(decoration: _shadowFrom(AlignmentDirectional.centerEnd))), ]); } } diff --git a/lib/widgets/theme.dart b/lib/widgets/theme.dart index 6039072116..5169a24a9b 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), @@ -259,6 +260,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), @@ -355,6 +357,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, @@ -443,6 +446,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; @@ -526,6 +530,7 @@ class DesignVariables extends ThemeExtension { Color? labelEdited, Color? labelMenuButton, Color? labelSearchPrompt, + Color? labelTime, Color? listMenuItemBg, Color? listMenuItemIcon, Color? listMenuItemText, @@ -604,6 +609,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, @@ -689,6 +695,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 221d6f6e4cf55b8f8768e187c424b6aa4247253d Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Mon, 14 Jul 2025 14:29:39 +0430 Subject: [PATCH 4/8] 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 16528 -> 16724 bytes assets/icons/check_check.svg | 4 ++ lib/widgets/icons.dart | 91 ++++++++++++++++++----------------- 3 files changed, 51 insertions(+), 44 deletions(-) create mode 100644 assets/icons/check_check.svg diff --git a/assets/icons/ZulipIcons.ttf b/assets/icons/ZulipIcons.ttf index 355d1e8dca9a8f48bc4f52dd6939855dba942a45..80c2fae7467c0292a29c382f0e205ced26fad882 100644 GIT binary patch delta 2249 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 + + \ No newline at end of file diff --git a/lib/widgets/icons.dart b/lib/widgets/icons.dart index 1b8457a9f7..2c1b59148f 100644 --- a/lib/widgets/icons.dart +++ b/lib/widgets/icons.dart @@ -48,137 +48,140 @@ 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_right". - static const IconData chevron_right = IconData(0xf10c, fontFamily: "Zulip Icons"); + static const IconData chevron_right = IconData(0xf10d, fontFamily: "Zulip Icons"); /// The Zulip custom icon "clock". - static const IconData clock = IconData(0xf10d, fontFamily: "Zulip Icons"); + static const IconData clock = IconData(0xf10e, fontFamily: "Zulip Icons"); /// The Zulip custom icon "contacts". - static const IconData contacts = IconData(0xf10e, fontFamily: "Zulip Icons"); + static const IconData contacts = IconData(0xf10f, fontFamily: "Zulip Icons"); /// The Zulip custom icon "copy". - static const IconData copy = IconData(0xf10f, fontFamily: "Zulip Icons"); + static const IconData copy = IconData(0xf110, fontFamily: "Zulip Icons"); /// The Zulip custom icon "edit". - static const IconData edit = IconData(0xf110, fontFamily: "Zulip Icons"); + static const IconData edit = IconData(0xf111, fontFamily: "Zulip Icons"); /// The Zulip custom icon "eye". - static const IconData eye = IconData(0xf111, fontFamily: "Zulip Icons"); + static const IconData eye = IconData(0xf112, fontFamily: "Zulip Icons"); /// The Zulip custom icon "eye_off". - static const IconData eye_off = IconData(0xf112, fontFamily: "Zulip Icons"); + static const IconData eye_off = IconData(0xf113, fontFamily: "Zulip Icons"); /// The Zulip custom icon "follow". - static const IconData follow = IconData(0xf113, fontFamily: "Zulip Icons"); + static const IconData follow = IconData(0xf114, fontFamily: "Zulip Icons"); /// The Zulip custom icon "format_quote". - static const IconData format_quote = IconData(0xf114, fontFamily: "Zulip Icons"); + static const IconData format_quote = IconData(0xf115, fontFamily: "Zulip Icons"); /// The Zulip custom icon "globe". - static const IconData globe = IconData(0xf115, fontFamily: "Zulip Icons"); + static const IconData globe = IconData(0xf116, fontFamily: "Zulip Icons"); /// The Zulip custom icon "group_dm". - static const IconData group_dm = IconData(0xf116, fontFamily: "Zulip Icons"); + static const IconData group_dm = IconData(0xf117, fontFamily: "Zulip Icons"); /// The Zulip custom icon "hash_italic". - static const IconData hash_italic = IconData(0xf117, fontFamily: "Zulip Icons"); + static const IconData hash_italic = IconData(0xf118, fontFamily: "Zulip Icons"); /// The Zulip custom icon "hash_sign". - static const IconData hash_sign = IconData(0xf118, fontFamily: "Zulip Icons"); + static const IconData hash_sign = IconData(0xf119, fontFamily: "Zulip Icons"); /// The Zulip custom icon "image". - static const IconData image = IconData(0xf119, fontFamily: "Zulip Icons"); + static const IconData image = IconData(0xf11a, fontFamily: "Zulip Icons"); /// The Zulip custom icon "inbox". - static const IconData inbox = IconData(0xf11a, fontFamily: "Zulip Icons"); + static const IconData inbox = IconData(0xf11b, fontFamily: "Zulip Icons"); /// The Zulip custom icon "info". - static const IconData info = IconData(0xf11b, fontFamily: "Zulip Icons"); + static const IconData info = IconData(0xf11c, fontFamily: "Zulip Icons"); /// The Zulip custom icon "inherit". - static const IconData inherit = IconData(0xf11c, fontFamily: "Zulip Icons"); + static const IconData inherit = IconData(0xf11d, fontFamily: "Zulip Icons"); /// The Zulip custom icon "language". - static const IconData language = IconData(0xf11d, fontFamily: "Zulip Icons"); + static const IconData language = IconData(0xf11e, fontFamily: "Zulip Icons"); /// The Zulip custom icon "lock". - static const IconData lock = IconData(0xf11e, fontFamily: "Zulip Icons"); + static const IconData lock = IconData(0xf11f, fontFamily: "Zulip Icons"); /// The Zulip custom icon "menu". - static const IconData menu = IconData(0xf11f, fontFamily: "Zulip Icons"); + static const IconData menu = IconData(0xf120, fontFamily: "Zulip Icons"); /// The Zulip custom icon "message_checked". - static const IconData message_checked = IconData(0xf120, fontFamily: "Zulip Icons"); + static const IconData message_checked = IconData(0xf121, fontFamily: "Zulip Icons"); /// The Zulip custom icon "message_feed". - static const IconData message_feed = IconData(0xf121, fontFamily: "Zulip Icons"); + static const IconData message_feed = IconData(0xf122, fontFamily: "Zulip Icons"); /// The Zulip custom icon "mute". - static const IconData mute = IconData(0xf122, fontFamily: "Zulip Icons"); + static const IconData mute = IconData(0xf123, fontFamily: "Zulip Icons"); /// The Zulip custom icon "person". - static const IconData person = IconData(0xf123, fontFamily: "Zulip Icons"); + static const IconData person = IconData(0xf124, fontFamily: "Zulip Icons"); /// The Zulip custom icon "plus". - static const IconData plus = IconData(0xf124, fontFamily: "Zulip Icons"); + static const IconData plus = IconData(0xf125, fontFamily: "Zulip Icons"); /// The Zulip custom icon "read_receipts". - static const IconData read_receipts = IconData(0xf125, fontFamily: "Zulip Icons"); + static const IconData read_receipts = IconData(0xf126, fontFamily: "Zulip Icons"); /// The Zulip custom icon "remove". - static const IconData remove = IconData(0xf126, fontFamily: "Zulip Icons"); + static const IconData remove = IconData(0xf127, fontFamily: "Zulip Icons"); /// The Zulip custom icon "search". - static const IconData search = IconData(0xf127, fontFamily: "Zulip Icons"); + static const IconData search = IconData(0xf128, fontFamily: "Zulip Icons"); /// The Zulip custom icon "see_who_reacted". - static const IconData see_who_reacted = IconData(0xf128, fontFamily: "Zulip Icons"); + static const IconData see_who_reacted = IconData(0xf129, fontFamily: "Zulip Icons"); /// The Zulip custom icon "send". - static const IconData send = IconData(0xf129, fontFamily: "Zulip Icons"); + static const IconData send = IconData(0xf12a, fontFamily: "Zulip Icons"); /// The Zulip custom icon "settings". - static const IconData settings = IconData(0xf12a, fontFamily: "Zulip Icons"); + static const IconData settings = IconData(0xf12b, fontFamily: "Zulip Icons"); /// The Zulip custom icon "share". - static const IconData share = IconData(0xf12b, fontFamily: "Zulip Icons"); + static const IconData share = IconData(0xf12c, fontFamily: "Zulip Icons"); /// The Zulip custom icon "share_ios". - static const IconData share_ios = IconData(0xf12c, fontFamily: "Zulip Icons"); + static const IconData share_ios = IconData(0xf12d, fontFamily: "Zulip Icons"); /// The Zulip custom icon "smile". - static const IconData smile = IconData(0xf12d, fontFamily: "Zulip Icons"); + static const IconData smile = IconData(0xf12e, fontFamily: "Zulip Icons"); /// The Zulip custom icon "star". - static const IconData star = IconData(0xf12e, fontFamily: "Zulip Icons"); + static const IconData star = IconData(0xf12f, fontFamily: "Zulip Icons"); /// The Zulip custom icon "star_filled". - static const IconData star_filled = IconData(0xf12f, fontFamily: "Zulip Icons"); + static const IconData star_filled = IconData(0xf130, fontFamily: "Zulip Icons"); /// The Zulip custom icon "three_person". - static const IconData three_person = IconData(0xf130, fontFamily: "Zulip Icons"); + static const IconData three_person = IconData(0xf131, fontFamily: "Zulip Icons"); /// The Zulip custom icon "topic". - static const IconData topic = IconData(0xf131, fontFamily: "Zulip Icons"); + static const IconData topic = IconData(0xf132, fontFamily: "Zulip Icons"); /// The Zulip custom icon "topics". - static const IconData topics = IconData(0xf132, fontFamily: "Zulip Icons"); + static const IconData topics = IconData(0xf133, fontFamily: "Zulip Icons"); /// The Zulip custom icon "two_person". - static const IconData two_person = IconData(0xf133, fontFamily: "Zulip Icons"); + static const IconData two_person = IconData(0xf134, fontFamily: "Zulip Icons"); /// The Zulip custom icon "unmute". - static const IconData unmute = IconData(0xf134, fontFamily: "Zulip Icons"); + static const IconData unmute = IconData(0xf135, fontFamily: "Zulip Icons"); // END GENERATED ICON DATA } From c93e68368bcfb1b00e75ca0712ad4fb038604ae1 Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Mon, 14 Jul 2025 14:35:23 +0430 Subject: [PATCH 5/8] action_sheet: Add 'View read receipts' button --- assets/l10n/app_en.arb | 4 ++++ lib/generated/l10n/zulip_localizations.dart | 6 ++++++ lib/generated/l10n/zulip_localizations_ar.dart | 3 +++ lib/generated/l10n/zulip_localizations_de.dart | 3 +++ lib/generated/l10n/zulip_localizations_en.dart | 3 +++ lib/generated/l10n/zulip_localizations_it.dart | 3 +++ lib/generated/l10n/zulip_localizations_ja.dart | 3 +++ lib/generated/l10n/zulip_localizations_nb.dart | 3 +++ lib/generated/l10n/zulip_localizations_pl.dart | 3 +++ lib/generated/l10n/zulip_localizations_ru.dart | 3 +++ lib/generated/l10n/zulip_localizations_sk.dart | 3 +++ lib/generated/l10n/zulip_localizations_sl.dart | 3 +++ lib/generated/l10n/zulip_localizations_uk.dart | 3 +++ lib/generated/l10n/zulip_localizations_zh.dart | 3 +++ lib/widgets/action_sheet.dart | 16 ++++++++++++++++ 15 files changed, 62 insertions(+) diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index 549e31ec69..28b67b468b 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -144,6 +144,10 @@ "@seeWhoReactedSheetNoReactions": { "description": "Explanation on the 'See who reacted' sheet when the message has no reactions (because they were removed after the sheet was opened)." }, + "actionSheetOptionViewReadReceipts": "View read receipts", + "@actionSheetOptionViewReadReceipts": { + "description": "Label for the 'View read receipts' button in the message action sheet." + }, "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 4f6766b486..7f11af07b1 100644 --- a/lib/generated/l10n/zulip_localizations.dart +++ b/lib/generated/l10n/zulip_localizations.dart @@ -345,6 +345,12 @@ abstract class ZulipLocalizations { /// **'This message has no reactions.'** String get seeWhoReactedSheetNoReactions; + /// Label for the 'View read receipts' button in the message action sheet. + /// + /// In en, this message translates to: + /// **'View read receipts'** + String get actionSheetOptionViewReadReceipts; + /// 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 846b3fc7df..8d731dcea0 100644 --- a/lib/generated/l10n/zulip_localizations_ar.dart +++ b/lib/generated/l10n/zulip_localizations_ar.dart @@ -124,6 +124,9 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get seeWhoReactedSheetNoReactions => 'This message has no reactions.'; + @override + String get actionSheetOptionViewReadReceipts => 'View 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 f6101e712e..65004fb340 100644 --- a/lib/generated/l10n/zulip_localizations_de.dart +++ b/lib/generated/l10n/zulip_localizations_de.dart @@ -127,6 +127,9 @@ class ZulipLocalizationsDe extends ZulipLocalizations { @override String get seeWhoReactedSheetNoReactions => 'This message has no reactions.'; + @override + String get actionSheetOptionViewReadReceipts => 'View 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 ba3579857c..f18a41e58b 100644 --- a/lib/generated/l10n/zulip_localizations_en.dart +++ b/lib/generated/l10n/zulip_localizations_en.dart @@ -124,6 +124,9 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get seeWhoReactedSheetNoReactions => 'This message has no reactions.'; + @override + String get actionSheetOptionViewReadReceipts => 'View 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 b3b5f5acb2..d5ea9187bf 100644 --- a/lib/generated/l10n/zulip_localizations_it.dart +++ b/lib/generated/l10n/zulip_localizations_it.dart @@ -126,6 +126,9 @@ class ZulipLocalizationsIt extends ZulipLocalizations { @override String get seeWhoReactedSheetNoReactions => 'This message has no reactions.'; + @override + String get actionSheetOptionViewReadReceipts => 'View 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 da8f0c7eaf..403a5da5d7 100644 --- a/lib/generated/l10n/zulip_localizations_ja.dart +++ b/lib/generated/l10n/zulip_localizations_ja.dart @@ -124,6 +124,9 @@ class ZulipLocalizationsJa extends ZulipLocalizations { @override String get seeWhoReactedSheetNoReactions => 'This message has no reactions.'; + @override + String get actionSheetOptionViewReadReceipts => 'View read receipts'; + @override String get actionSheetOptionCopyMessageText => 'Copy message text'; diff --git a/lib/generated/l10n/zulip_localizations_nb.dart b/lib/generated/l10n/zulip_localizations_nb.dart index 38793e3633..723f050251 100644 --- a/lib/generated/l10n/zulip_localizations_nb.dart +++ b/lib/generated/l10n/zulip_localizations_nb.dart @@ -124,6 +124,9 @@ class ZulipLocalizationsNb extends ZulipLocalizations { @override String get seeWhoReactedSheetNoReactions => 'This message has no reactions.'; + @override + String get actionSheetOptionViewReadReceipts => 'View 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 73324c039b..2e5b6b3255 100644 --- a/lib/generated/l10n/zulip_localizations_pl.dart +++ b/lib/generated/l10n/zulip_localizations_pl.dart @@ -127,6 +127,9 @@ class ZulipLocalizationsPl extends ZulipLocalizations { @override String get seeWhoReactedSheetNoReactions => 'This message has no reactions.'; + @override + String get actionSheetOptionViewReadReceipts => 'View 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 4093a57f9a..8c46f37c54 100644 --- a/lib/generated/l10n/zulip_localizations_ru.dart +++ b/lib/generated/l10n/zulip_localizations_ru.dart @@ -127,6 +127,9 @@ class ZulipLocalizationsRu extends ZulipLocalizations { @override String get seeWhoReactedSheetNoReactions => 'This message has no reactions.'; + @override + String get actionSheetOptionViewReadReceipts => 'View 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 113e73e7b7..6fd3517d3b 100644 --- a/lib/generated/l10n/zulip_localizations_sk.dart +++ b/lib/generated/l10n/zulip_localizations_sk.dart @@ -124,6 +124,9 @@ class ZulipLocalizationsSk extends ZulipLocalizations { @override String get seeWhoReactedSheetNoReactions => 'This message has no reactions.'; + @override + String get actionSheetOptionViewReadReceipts => 'View 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 ae43ea7853..562fa952b9 100644 --- a/lib/generated/l10n/zulip_localizations_sl.dart +++ b/lib/generated/l10n/zulip_localizations_sl.dart @@ -125,6 +125,9 @@ class ZulipLocalizationsSl extends ZulipLocalizations { @override String get seeWhoReactedSheetNoReactions => 'This message has no reactions.'; + @override + String get actionSheetOptionViewReadReceipts => 'View 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 2c130cfc85..203770b26b 100644 --- a/lib/generated/l10n/zulip_localizations_uk.dart +++ b/lib/generated/l10n/zulip_localizations_uk.dart @@ -128,6 +128,9 @@ class ZulipLocalizationsUk extends ZulipLocalizations { @override String get seeWhoReactedSheetNoReactions => 'This message has no reactions.'; + @override + String get actionSheetOptionViewReadReceipts => 'View 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 7d93ad7c11..321e8648ba 100644 --- a/lib/generated/l10n/zulip_localizations_zh.dart +++ b/lib/generated/l10n/zulip_localizations_zh.dart @@ -124,6 +124,9 @@ class ZulipLocalizationsZh extends ZulipLocalizations { @override String get seeWhoReactedSheetNoReactions => 'This message has no reactions.'; + @override + String get actionSheetOptionViewReadReceipts => 'View read receipts'; + @override String get actionSheetOptionCopyMessageText => 'Copy message text'; diff --git a/lib/widgets/action_sheet.dart b/lib/widgets/action_sheet.dart index 1a490451f7..51fc2576b3 100644 --- a/lib/widgets/action_sheet.dart +++ b/lib/widgets/action_sheet.dart @@ -661,6 +661,7 @@ void showMessageActionSheet({required BuildContext context, required Message mes if (popularEmojiLoaded) ReactionButtons(message: message, pageContext: pageContext), ViewReactionsButton(message: message, pageContext: pageContext), + ViewReadReceiptsButton(message: message, pageContext: pageContext), StarButton(message: message, pageContext: pageContext), if (isComposeBoxOffered) QuoteAndReplyButton(message: message, pageContext: pageContext), @@ -902,6 +903,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() { + // TODO: open read receipts sheet + } +} + class StarButton extends MessageActionSheetMenuItemButton { StarButton({super.key, required super.message, required super.pageContext}); From fa6413f1ae89ae749ed2d7abbfa2ea84cbb49781 Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Tue, 15 Jul 2025 14:08:47 +0430 Subject: [PATCH 6/8] action_sheet [nfc]: Rename BottomSheetHeaderPlainText to BottomSheetPlainText 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 middle of bottom sheet to give feedback to the user. This also adds a TextAlign property for controlling the alignment. --- lib/widgets/action_sheet.dart | 12 +++++++----- lib/widgets/emoji_reaction.dart | 4 ++-- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/lib/widgets/action_sheet.dart b/lib/widgets/action_sheet.dart index 51fc2576b3..551c21871e 100644 --- a/lib/widgets/action_sheet.dart +++ b/lib/widgets/action_sheet.dart @@ -98,26 +98,28 @@ 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. +/// 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 BottomSheetPlainText extends StatelessWidget { + const BottomSheetPlainText({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 b735936a68..2e8125e76a 100644 --- a/lib/widgets/emoji_reaction.dart +++ b/lib/widgets/emoji_reaction.dart @@ -782,8 +782,8 @@ class ViewReactionsHeader extends StatelessWidget { if (reactionsWithVotes == null || reactionsWithVotes.isEmpty) { return Padding( - padding: const EdgeInsets.only(top: 8), - child: BottomSheetHeaderPlainText(text: zulipLocalizations.seeWhoReactedSheetNoReactions), + padding: const EdgeInsets.only(top: 16, bottom: 4), + child: BottomSheetPlainText(text: zulipLocalizations.seeWhoReactedSheetNoReactions), ); } From a6fc3348e2aa6bcc95c3c20965a20c3cdabadc37 Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Tue, 15 Jul 2025 14:05:36 +0430 Subject: [PATCH 7/8] deps: Add styled_text --- pubspec.lock | 16 ++++++++++++++++ pubspec.yaml | 1 + 2 files changed, 17 insertions(+) diff --git a/pubspec.lock b/pubspec.lock index e693e77f23..070db06efc 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 a87dc4a4bd..82c68b9145 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 d05ac9ddd45fe75c4fdadf21762d45287490071c Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Mon, 14 Jul 2025 14:40:13 +0430 Subject: [PATCH 8/8] read_receipts: Add "Read receipts" bottom sheet Fixes: #667 --- assets/l10n/app_en.arb | 19 ++ lib/generated/l10n/zulip_localizations.dart | 24 ++ .../l10n/zulip_localizations_ar.dart | 22 ++ .../l10n/zulip_localizations_de.dart | 22 ++ .../l10n/zulip_localizations_en.dart | 22 ++ .../l10n/zulip_localizations_it.dart | 22 ++ .../l10n/zulip_localizations_ja.dart | 22 ++ .../l10n/zulip_localizations_nb.dart | 22 ++ .../l10n/zulip_localizations_pl.dart | 22 ++ .../l10n/zulip_localizations_ru.dart | 22 ++ .../l10n/zulip_localizations_sk.dart | 22 ++ .../l10n/zulip_localizations_sl.dart | 22 ++ .../l10n/zulip_localizations_uk.dart | 22 ++ .../l10n/zulip_localizations_zh.dart | 22 ++ lib/widgets/action_sheet.dart | 3 +- lib/widgets/read_receipts.dart | 243 ++++++++++++++++++ lib/widgets/read_receipts.g.dart | 21 ++ lib/widgets/theme.dart | 7 + 18 files changed, 580 insertions(+), 1 deletion(-) create mode 100644 lib/widgets/read_receipts.dart create mode 100644 lib/widgets/read_receipts.g.dart diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index 28b67b468b..757097c67f 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -148,6 +148,25 @@ "@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 7f11af07b1..691267a036 100644 --- a/lib/generated/l10n/zulip_localizations.dart +++ b/lib/generated/l10n/zulip_localizations.dart @@ -351,6 +351,30 @@ abstract class ZulipLocalizations { /// **'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 8d731dcea0..f1324e00f0 100644 --- a/lib/generated/l10n/zulip_localizations_ar.dart +++ b/lib/generated/l10n/zulip_localizations_ar.dart @@ -127,6 +127,28 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @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 65004fb340..8eb9fe4672 100644 --- a/lib/generated/l10n/zulip_localizations_de.dart +++ b/lib/generated/l10n/zulip_localizations_de.dart @@ -130,6 +130,28 @@ class ZulipLocalizationsDe extends ZulipLocalizations { @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 f18a41e58b..3d4de28f09 100644 --- a/lib/generated/l10n/zulip_localizations_en.dart +++ b/lib/generated/l10n/zulip_localizations_en.dart @@ -127,6 +127,28 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @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 d5ea9187bf..c78b349c65 100644 --- a/lib/generated/l10n/zulip_localizations_it.dart +++ b/lib/generated/l10n/zulip_localizations_it.dart @@ -129,6 +129,28 @@ class ZulipLocalizationsIt extends ZulipLocalizations { @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 403a5da5d7..153fb83446 100644 --- a/lib/generated/l10n/zulip_localizations_ja.dart +++ b/lib/generated/l10n/zulip_localizations_ja.dart @@ -127,6 +127,28 @@ class ZulipLocalizationsJa extends ZulipLocalizations { @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_nb.dart b/lib/generated/l10n/zulip_localizations_nb.dart index 723f050251..fff1d7ce4a 100644 --- a/lib/generated/l10n/zulip_localizations_nb.dart +++ b/lib/generated/l10n/zulip_localizations_nb.dart @@ -127,6 +127,28 @@ class ZulipLocalizationsNb extends ZulipLocalizations { @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 2e5b6b3255..fe4fea417b 100644 --- a/lib/generated/l10n/zulip_localizations_pl.dart +++ b/lib/generated/l10n/zulip_localizations_pl.dart @@ -130,6 +130,28 @@ class ZulipLocalizationsPl extends ZulipLocalizations { @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 8c46f37c54..781e7f6fa9 100644 --- a/lib/generated/l10n/zulip_localizations_ru.dart +++ b/lib/generated/l10n/zulip_localizations_ru.dart @@ -130,6 +130,28 @@ class ZulipLocalizationsRu extends ZulipLocalizations { @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 6fd3517d3b..40792f7a3d 100644 --- a/lib/generated/l10n/zulip_localizations_sk.dart +++ b/lib/generated/l10n/zulip_localizations_sk.dart @@ -127,6 +127,28 @@ class ZulipLocalizationsSk extends ZulipLocalizations { @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 562fa952b9..93f45509f0 100644 --- a/lib/generated/l10n/zulip_localizations_sl.dart +++ b/lib/generated/l10n/zulip_localizations_sl.dart @@ -128,6 +128,28 @@ class ZulipLocalizationsSl extends ZulipLocalizations { @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 203770b26b..f15d2fd2d4 100644 --- a/lib/generated/l10n/zulip_localizations_uk.dart +++ b/lib/generated/l10n/zulip_localizations_uk.dart @@ -131,6 +131,28 @@ class ZulipLocalizationsUk extends ZulipLocalizations { @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 321e8648ba..06b25c0212 100644 --- a/lib/generated/l10n/zulip_localizations_zh.dart +++ b/lib/generated/l10n/zulip_localizations_zh.dart @@ -127,6 +127,28 @@ class ZulipLocalizationsZh extends ZulipLocalizations { @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 551c21871e..2a5bf6a3aa 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'; @@ -916,7 +917,7 @@ class ViewReadReceiptsButton extends MessageActionSheetMenuItemButton { } @override void onPressed() { - // TODO: open read receipts sheet + showReadReceiptsSheet(pageContext, messageId: message.id); } } diff --git a/lib/widgets/read_receipts.dart b/lib/widgets/read_receipts.dart new file mode 100644 index 0000000000..f28bbca8e0 --- /dev/null +++ b/lib/widgets/read_receipts.dart @@ -0,0 +1,243 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:json_annotation/json_annotation.dart'; +import 'package:styled_text/styled_text.dart'; + +import '../api/core.dart'; +import '../generated/l10n/zulip_localizations.dart'; +import 'action_sheet.dart'; +import 'actions.dart'; +import 'color.dart'; +import 'content.dart'; +import 'inset_shadow.dart'; +import 'profile.dart'; +import 'store.dart'; +import 'text.dart'; +import 'theme.dart'; + +part 'read_receipts.g.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: status == FetchStatus.loading + ? CircularProgressIndicator() + : status == FetchStatus.error + ? BottomSheetPlainText( + text: localizations.actionSheetReadReceiptsErrorReadCount, + textAlign: TextAlign.center) + : userIds.isEmpty + ? BottomSheetPlainText( + 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(context, userId: userIds[index])))); + } +} + + +// TODO: deduplicate the code with [ViewReactionsUserItem] +@visibleForTesting +class ReadReceiptsUserItem extends StatelessWidget { + const ReadReceiptsUserItem(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))), + ]))); + } +} + +/// 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/widgets/read_receipts.g.dart b/lib/widgets/read_receipts.g.dart new file mode 100644 index 0000000000..254e23fd94 --- /dev/null +++ b/lib/widgets/read_receipts.g.dart @@ -0,0 +1,21 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +// ignore_for_file: constant_identifier_names, unnecessary_cast + +part of 'read_receipts.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +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}; diff --git a/lib/widgets/theme.dart b/lib/widgets/theme.dart index 5169a24a9b..0dc2998c60 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), @@ -261,6 +262,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), @@ -358,6 +360,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, @@ -447,6 +450,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; @@ -531,6 +535,7 @@ class DesignVariables extends ThemeExtension { Color? labelMenuButton, Color? labelSearchPrompt, Color? labelTime, + Color? link, Color? listMenuItemBg, Color? listMenuItemIcon, Color? listMenuItemText, @@ -610,6 +615,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, @@ -696,6 +702,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)!,