1
+ import 'dart:math' as math;
2
+
3
+ import 'package:flutter/foundation.dart' ;
1
4
import 'package:flutter/material.dart' ;
2
5
import 'package:flutter/services.dart' ;
3
6
import 'package:flutter_gen/gen_l10n/zulip_localizations.dart' ;
@@ -12,51 +15,201 @@ import 'actions.dart';
12
15
import 'clipboard.dart' ;
13
16
import 'compose_box.dart' ;
14
17
import 'dialog.dart' ;
15
- import 'draggable_scrollable_modal_bottom_sheet.dart' ;
16
18
import 'icons.dart' ;
17
19
import 'message_list.dart' ;
18
20
import 'store.dart' ;
21
+ import 'text.dart' ;
22
+ import 'theme.dart' ;
19
23
20
24
/// Show a sheet of actions you can take on a message in the message list.
21
25
///
22
26
/// Must have a [MessageListPage] ancestor.
23
27
void showMessageActionSheet ({required BuildContext context, required Message message}) {
24
- final store = PerAccountStoreWidget .of (context);
25
-
26
- // The UI that's conditioned on this won't live-update during this appearance
27
- // of the action sheet (we avoid calling composeBoxControllerOf in a build
28
- // method; see its doc). But currently it will be constant through the life of
29
- // any message list, so that's fine.
30
- final messageListPage = MessageListPage .ancestorOf (context);
31
- final isComposeBoxOffered = messageListPage.composeBoxController != null ;
32
- final narrow = messageListPage.narrow;
33
- final isMessageRead = message.flags.contains (MessageFlag .read);
34
- final markAsUnreadSupported = store.connection.zulipFeatureLevel! >= 155 ; // TODO(server-6)
35
- final showMarkAsUnreadButton = markAsUnreadSupported && isMessageRead;
36
-
37
- final hasThumbsUpReactionVote = message.reactions
38
- ? .aggregated.any ((reactionWithVotes) =>
39
- reactionWithVotes.reactionType == ReactionType .unicodeEmoji
40
- && reactionWithVotes.emojiCode == '1f44d'
41
- && reactionWithVotes.userIds.contains (store.selfUserId))
42
- ?? false ;
43
-
44
- showDraggableScrollableModalBottomSheet <void >(
28
+ showModalBottomSheet <void >(
45
29
context: context,
46
- builder: (BuildContext _) {
47
- return Column (children: [
48
- if (! hasThumbsUpReactionVote)
49
- AddThumbsUpButton (message: message, messageListContext: context),
50
- StarButton (message: message, messageListContext: context),
51
- if (isComposeBoxOffered)
52
- QuoteAndReplyButton (message: message, messageListContext: context),
53
- if (showMarkAsUnreadButton)
54
- MarkAsUnreadButton (message: message, messageListContext: context, narrow: narrow),
55
- CopyMessageTextButton (message: message, messageListContext: context),
56
- CopyMessageLinkButton (message: message, messageListContext: context),
57
- ShareButton (message: message, messageListContext: context),
58
- ]);
59
- });
30
+ useSafeArea: true ,
31
+ isScrollControlled: true ,
32
+ builder: (BuildContext _) => _MessageActionSheet (messageListContext: context,
33
+ message: message));
34
+ }
35
+
36
+ class _MessageActionSheet extends StatefulWidget {
37
+ const _MessageActionSheet ({
38
+ required this .messageListContext,
39
+ required this .message,
40
+ });
41
+
42
+ final BuildContext messageListContext;
43
+ final Message message;
44
+
45
+ @override
46
+ State <_MessageActionSheet > createState () => _MessageActionSheetState ();
47
+ }
48
+
49
+ class _MessageActionSheetState extends State <_MessageActionSheet > {
50
+ late final ScrollController scrollController = ScrollController ();
51
+
52
+ @override
53
+ void dispose () {
54
+ scrollController.dispose ();
55
+ super .dispose ();
56
+ }
57
+
58
+ @override
59
+ Widget build (BuildContext context) {
60
+ final store = PerAccountStoreWidget .of (widget.messageListContext);
61
+
62
+ // The UI that's conditioned on this won't live-update during this appearance
63
+ // of the action sheet (we avoid calling composeBoxControllerOf in a build
64
+ // method; see its doc). But currently it will be constant through the life of
65
+ // any message list, so that's fine.
66
+ final messageListPage = MessageListPage .ancestorOf (widget.messageListContext);
67
+ final isComposeBoxOffered = messageListPage.composeBoxController != null ;
68
+ final narrow = messageListPage.narrow;
69
+ final isMessageRead = widget.message.flags.contains (MessageFlag .read);
70
+ final markAsUnreadSupported = store.connection.zulipFeatureLevel! >= 155 ; // TODO(server-6)
71
+ final showMarkAsUnreadButton = markAsUnreadSupported && isMessageRead;
72
+
73
+ final hasThumbsUpReactionVote = widget.message.reactions
74
+ ? .aggregated.any ((reactionWithVotes) =>
75
+ reactionWithVotes.reactionType == ReactionType .unicodeEmoji
76
+ && reactionWithVotes.emojiCode == '1f44d'
77
+ && reactionWithVotes.userIds.contains (store.selfUserId))
78
+ ?? false ;
79
+
80
+ final optionButtons = List .filled (3 , [
81
+ if (! hasThumbsUpReactionVote)
82
+ AddThumbsUpButton (message: widget.message, messageListContext: widget.messageListContext),
83
+ StarButton (message: widget.message, messageListContext: widget.messageListContext),
84
+ if (isComposeBoxOffered)
85
+ QuoteAndReplyButton (message: widget.message, messageListContext: widget.messageListContext),
86
+ if (showMarkAsUnreadButton)
87
+ MarkAsUnreadButton (message: widget.message, messageListContext: widget.messageListContext, narrow: narrow),
88
+ CopyMessageTextButton (message: widget.message, messageListContext: widget.messageListContext),
89
+ CopyMessageLinkButton (message: widget.message, messageListContext: widget.messageListContext),
90
+ ShareButton (message: widget.message, messageListContext: widget.messageListContext),
91
+ ]).expand ((e) => e).toList ();
92
+
93
+ // Pad the bottom inset. The left/top/right insets are already handled by
94
+ // `showModalBottomSheet.useSafeArea: true` above, which keeps the sheet
95
+ // out of those insets.
96
+ return SafeArea (
97
+ minimum: const EdgeInsets .only (bottom: 16 ),
98
+ child: Padding (
99
+ padding: const EdgeInsets .fromLTRB (16 , 0 , 16 , 0 ),
100
+ child: Column (
101
+ crossAxisAlignment: CrossAxisAlignment .stretch,
102
+ mainAxisSize: MainAxisSize .min,
103
+ children: [
104
+ // TODO(#217): show message text
105
+ Flexible (
106
+ child: Stack (
107
+ children: [
108
+ Column (
109
+ mainAxisSize: MainAxisSize .min,
110
+ children: [
111
+ // Serves as the top dynamic padding, which changes when
112
+ // the action sheet is scrolled in the upper direction.
113
+ _ScrollControllerBuilder (
114
+ scrollController: scrollController,
115
+ builder: (_, scrollController) => SizedBox (
116
+ height: math.max (
117
+ 16 - scrollController.position.extentBefore,
118
+ 0 ,
119
+ )),
120
+ ),
121
+ Flexible (child: SingleChildScrollView (
122
+ controller: scrollController,
123
+ child: ClipRRect (
124
+ borderRadius: BorderRadius .circular (7 ),
125
+ child: Column (spacing: 1 , children: optionButtons),
126
+ )),
127
+ ),
128
+ // Serves as the bottom dynamic padding, which changes when
129
+ // the action sheet is scrolled in the lower direction.
130
+ _ScrollControllerBuilder (
131
+ scrollController: scrollController,
132
+ builder: (_, __) => SizedBox (
133
+ height: math.max (
134
+ 8 - scrollController.position.extentAfter,
135
+ 0 ,
136
+ )),
137
+ ),
138
+ ],
139
+ ),
140
+ // Serves as the top shadow:
141
+ // https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=3483-42600&t=QvIOvrQk9Rz63aKM-1
142
+ _ScrollControllerBuilder (
143
+ scrollController: scrollController,
144
+ builder: (_, scrollController) {
145
+ final designVariables = DesignVariables .of (context);
146
+ return Positioned .fill (
147
+ top: math.max (8 - scrollController.position.extentBefore, 0 ),
148
+ bottom: null ,
149
+ child: Container (
150
+ height: math.min (scrollController.position.extentBefore, 16 ),
151
+ decoration: BoxDecoration (
152
+ gradient: LinearGradient (
153
+ begin: Alignment .topCenter,
154
+ end: Alignment .bottomCenter,
155
+ colors: [
156
+ designVariables.bgContextMenu,
157
+ designVariables.bgContextMenu.withOpacity (0 ),
158
+ ]))));
159
+ },
160
+ ),
161
+ // Serves as the bottom shadow:
162
+ // https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=3483-42291&t=VYchuPwHKwZO0Ig4-0
163
+ _ScrollControllerBuilder (
164
+ scrollController: scrollController,
165
+ builder: (_, scrollController) {
166
+ final designVariables = DesignVariables .of (context);
167
+ return Positioned .fill (
168
+ top: null ,
169
+ bottom: math.max (8 - scrollController.position.extentAfter, 0 ),
170
+ child: Container (
171
+ height: math.min (scrollController.position.extentAfter, 8 ),
172
+ decoration: BoxDecoration (
173
+ gradient: LinearGradient (
174
+ begin: Alignment .topCenter,
175
+ end: Alignment .bottomCenter,
176
+ colors: [
177
+ designVariables.bgContextMenu.withOpacity (0 ),
178
+ designVariables.bgContextMenu,
179
+ ]))));
180
+ },
181
+ ),
182
+ ],
183
+ ),
184
+ ),
185
+ const MessageActionSheetCancelButton (),
186
+ ],
187
+ ),
188
+ ),
189
+ );
190
+ }
191
+ }
192
+
193
+ class _ScrollControllerBuilder extends StatelessWidget {
194
+ const _ScrollControllerBuilder ({required this .scrollController, required this .builder});
195
+
196
+ final ScrollController scrollController;
197
+ final Widget Function (BuildContext , ScrollController ) builder;
198
+
199
+ @override
200
+ Widget build (BuildContext context) {
201
+ return FutureBuilder (
202
+ future: Future .microtask (() => true ),
203
+ builder: (context, snapshot) {
204
+ if (! snapshot.hasData) {
205
+ return const SizedBox .shrink ();
206
+ }
207
+ return ListenableBuilder (
208
+ listenable: scrollController,
209
+ builder: (context, __) => builder (context, scrollController),
210
+ );
211
+ });
212
+ }
60
213
}
61
214
62
215
abstract class MessageActionSheetMenuItemButton extends StatelessWidget {
@@ -75,11 +228,22 @@ abstract class MessageActionSheetMenuItemButton extends StatelessWidget {
75
228
76
229
@override
77
230
Widget build (BuildContext context) {
231
+ final designVariables = DesignVariables .of (context);
78
232
final zulipLocalizations = ZulipLocalizations .of (context);
79
233
return MenuItemButton (
80
- leadingIcon: Icon (icon),
234
+ trailingIcon: Icon (icon, color: designVariables.contextMenuItemText),
235
+ style: MenuItemButton .styleFrom (
236
+ padding: const EdgeInsets .symmetric (vertical: 12 , horizontal: 16 ),
237
+ foregroundColor: designVariables.contextMenuItemText,
238
+ splashFactory: NoSplash .splashFactory,
239
+ ).copyWith (backgroundColor: WidgetStateColor .resolveWith ((states) =>
240
+ designVariables.contextMenuItemBg.withOpacity (
241
+ states.contains (WidgetState .pressed) ? 0.20 : 0.12 ))),
81
242
onPressed: () => onPressed (context),
82
- child: Text (label (zulipLocalizations)));
243
+ child: Text (label (zulipLocalizations),
244
+ style: const TextStyle (fontSize: 20 , height: 24 / 20 )
245
+ .merge (weightVariableTextStyle (context, wght: 600 )),
246
+ ));
83
247
}
84
248
}
85
249
@@ -92,7 +256,7 @@ class AddThumbsUpButton extends MessageActionSheetMenuItemButton {
92
256
required super .messageListContext,
93
257
});
94
258
95
- @override IconData get icon => Icons .add_reaction_outlined ;
259
+ @override IconData get icon => ZulipIcons .smile ;
96
260
97
261
@override
98
262
String label (ZulipLocalizations zulipLocalizations) {
@@ -133,11 +297,13 @@ class StarButton extends MessageActionSheetMenuItemButton {
133
297
required super .messageListContext,
134
298
});
135
299
136
- @override IconData get icon => ZulipIcons .star_filled;
300
+ @override IconData get icon => _isStarred ? ZulipIcons .star_filled : ZulipIcons .star;
301
+
302
+ bool get _isStarred => message.flags.contains (MessageFlag .starred);
137
303
138
304
@override
139
305
String label (ZulipLocalizations zulipLocalizations) {
140
- return message.flags. contains ( MessageFlag .starred)
306
+ return _isStarred
141
307
? zulipLocalizations.actionSheetOptionUnstarMessage
142
308
: zulipLocalizations.actionSheetOptionStarMessage;
143
309
}
@@ -229,7 +395,7 @@ class QuoteAndReplyButton extends MessageActionSheetMenuItemButton {
229
395
required super .messageListContext,
230
396
});
231
397
232
- @override IconData get icon => Icons .format_quote_outlined ;
398
+ @override IconData get icon => ZulipIcons .format_quote ;
233
399
234
400
@override
235
401
String label (ZulipLocalizations zulipLocalizations) {
@@ -314,7 +480,7 @@ class CopyMessageTextButton extends MessageActionSheetMenuItemButton {
314
480
required super .messageListContext,
315
481
});
316
482
317
- @override IconData get icon => Icons .copy;
483
+ @override IconData get icon => ZulipIcons .copy;
318
484
319
485
@override
320
486
String label (ZulipLocalizations zulipLocalizations) {
@@ -382,7 +548,10 @@ class ShareButton extends MessageActionSheetMenuItemButton {
382
548
required super .messageListContext,
383
549
});
384
550
385
- @override IconData get icon => Icons .adaptive.share;
551
+ @override
552
+ IconData get icon => defaultTargetPlatform == TargetPlatform .iOS
553
+ ? ZulipIcons .share_ios
554
+ : ZulipIcons .share;
386
555
387
556
@override
388
557
String label (ZulipLocalizations zulipLocalizations) {
@@ -431,3 +600,30 @@ class ShareButton extends MessageActionSheetMenuItemButton {
431
600
}
432
601
}
433
602
}
603
+
604
+ class MessageActionSheetCancelButton extends StatelessWidget {
605
+ const MessageActionSheetCancelButton ({super .key});
606
+
607
+ @override
608
+ Widget build (BuildContext context) {
609
+ final designVariables = DesignVariables .of (context);
610
+ return TextButton (
611
+ style: TextButton .styleFrom (
612
+ padding: const EdgeInsets .all (10 ),
613
+ tapTargetSize: MaterialTapTargetSize .shrinkWrap,
614
+ minimumSize: Size .zero,
615
+ foregroundColor: designVariables.contextMenuCancelText,
616
+ shape: RoundedRectangleBorder (borderRadius: BorderRadius .circular (7 )),
617
+ splashFactory: NoSplash .splashFactory,
618
+ ).copyWith (backgroundColor: WidgetStateColor .resolveWith ((states) =>
619
+ designVariables.contextMenuCancelBg.withOpacity (
620
+ states.contains (WidgetState .pressed) ? 0.20 : 0.15 ))),
621
+ onPressed: () {
622
+ Navigator .pop (context);
623
+ },
624
+ child: Text (ZulipLocalizations .of (context).dialogCancel,
625
+ style: const TextStyle (fontSize: 20 , height: 24 / 20 )
626
+ .merge (weightVariableTextStyle (context, wght: 600 ))),
627
+ );
628
+ }
629
+ }
0 commit comments