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,55 +15,215 @@ 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) AddThumbsUpButton (message: message, messageListContext: context),
49
- StarButton (message: message, messageListContext: context),
50
- if (isComposeBoxOffered) QuoteAndReplyButton (
51
- message: message,
52
- messageListContext: context,
53
- ),
54
- if (showMarkAsUnreadButton) MarkAsUnreadButton (
55
- message: message,
56
- messageListContext: context,
57
- narrow: narrow,
30
+ useSafeArea: true ,
31
+ isScrollControlled: true ,
32
+ builder: (BuildContext _) => _ActionSheet (messageListContext: context,
33
+ message: message));
34
+ }
35
+
36
+ class _ActionSheet extends StatefulWidget {
37
+ const _ActionSheet ({
38
+ required this .messageListContext,
39
+ required this .message,
40
+ });
41
+
42
+ final BuildContext messageListContext;
43
+ final Message message;
44
+
45
+ @override
46
+ State <_ActionSheet > createState () => _ActionSheetState ();
47
+ }
48
+
49
+ class _ActionSheetState extends State <_ActionSheet > {
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
+ // Pad the bottom inset. The left/top/right insets are already handled by
81
+ // `useSafeArea: true` above, which keeps the sheet out of those insets.
82
+ return SafeArea (
83
+ minimum: const EdgeInsets .only (bottom: 16 ),
84
+ child: Padding (
85
+ padding: const EdgeInsets .fromLTRB (16 , 0 , 16 , 0 ),
86
+ child: Column (
87
+ crossAxisAlignment: CrossAxisAlignment .stretch,
88
+ mainAxisSize: MainAxisSize .min,
89
+ children: [
90
+ // TODO(#217): show message text
91
+ Flexible (
92
+ child: Stack (
93
+ children: [
94
+ Column (
95
+ mainAxisSize: MainAxisSize .min,
96
+ children: [
97
+ // Serves as the top dynamic padding, which changes when
98
+ // the action sheet is scrolled in the upper direction.
99
+ _ScrollControllerBuilder (
100
+ scrollController: scrollController,
101
+ builder: (_, scrollController) => SizedBox (
102
+ height: math.max (
103
+ 16 - scrollController.position.extentBefore,
104
+ 0 ,
105
+ )),
106
+ ),
107
+ Flexible (
108
+ child: SingleChildScrollView (
109
+ controller: scrollController,
110
+ child: ClipRRect (
111
+ borderRadius: BorderRadius .circular (7 ),
112
+ child: Column (spacing: 1 , children: [
113
+ if (! hasThumbsUpReactionVote) AddThumbsUpButton (
114
+ message: widget.message,
115
+ messageListContext: widget.messageListContext,
116
+ ),
117
+ StarButton (message: widget.message, messageListContext: widget.messageListContext),
118
+ if (isComposeBoxOffered) QuoteAndReplyButton (
119
+ message: widget.message,
120
+ messageListContext: widget.messageListContext,
121
+ ),
122
+ if (showMarkAsUnreadButton) MarkAsUnreadButton (
123
+ message: widget.message,
124
+ messageListContext: widget.messageListContext,
125
+ narrow: narrow,
126
+ ),
127
+ CopyMessageTextButton (message: widget.message, messageListContext: widget.messageListContext),
128
+ CopyMessageLinkButton (message: widget.message, messageListContext: widget.messageListContext),
129
+ ShareButton (message: widget.message, messageListContext: widget.messageListContext),
130
+ ]),
131
+ ),
132
+ ),
133
+ ),
134
+ // Serves as the bottom dynamic padding, which changes when
135
+ // the action sheet is scrolled in the lower direction.
136
+ _ScrollControllerBuilder (
137
+ scrollController: scrollController,
138
+ builder: (_, __) => SizedBox (
139
+ height: math.max (
140
+ 8 - scrollController.position.extentAfter,
141
+ 0 ,
142
+ )),
143
+ ),
144
+ ],
145
+ ),
146
+ // Serves as the top shadow:
147
+ // https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=3483-42600&t=QvIOvrQk9Rz63aKM-1
148
+ _ScrollControllerBuilder (
149
+ scrollController: scrollController,
150
+ builder: (_, scrollController) {
151
+ final designVariables = DesignVariables .of (context);
152
+ return Positioned .fill (
153
+ top: math.max (16 - scrollController.position.extentBefore, 0 ),
154
+ bottom: null ,
155
+ child: Container (
156
+ height: math.min (scrollController.position.extentBefore, 16 ),
157
+ decoration: BoxDecoration (
158
+ gradient: LinearGradient (
159
+ begin: Alignment .topCenter,
160
+ end: Alignment .bottomCenter,
161
+ colors: [
162
+ designVariables.bgContextMenu,
163
+ designVariables.bgContextMenu.withOpacity (0 ),
164
+ ],
165
+ ),
166
+ ),
167
+ ),
168
+ );
169
+ }
170
+ ),
171
+ // Serves as the bottom shadow:
172
+ // https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=3483-42291&t=3rYe12EFTQL3wfZB-1
173
+ _ScrollControllerBuilder (
174
+ scrollController: scrollController,
175
+ builder: (_, scrollController) {
176
+ final designVariables = DesignVariables .of (context);
177
+ return Positioned .fill (
178
+ top: null ,
179
+ bottom: math.max (8 - scrollController.position.extentAfter, 0 ),
180
+ child: Container (
181
+ height: math.min (scrollController.position.extentAfter, 8 ),
182
+ decoration: BoxDecoration (
183
+ gradient: LinearGradient (
184
+ begin: Alignment .topCenter,
185
+ end: Alignment .bottomCenter,
186
+ colors: [
187
+ designVariables.bgContextMenu.withOpacity (0 ),
188
+ designVariables.bgContextMenu,
189
+ ],
190
+ ),
191
+ ),
192
+ ),
193
+ );
194
+ }
195
+ ),
196
+ ],
197
+ ),
198
+ ),
199
+ const MessageActionSheetCancelButton (),
200
+ ],
58
201
),
59
- CopyMessageTextButton (message: message, messageListContext: context),
60
- CopyMessageLinkButton (message: message, messageListContext: context),
61
- ShareButton (message: message, messageListContext: context),
62
- ]);
63
- });
202
+ ),
203
+ );
204
+ }
205
+ }
206
+
207
+ class _ScrollControllerBuilder extends StatelessWidget {
208
+ const _ScrollControllerBuilder ({required this .scrollController, required this .builder});
209
+
210
+ final ScrollController scrollController;
211
+ final Widget Function (BuildContext , ScrollController ) builder;
212
+
213
+ @override
214
+ Widget build (BuildContext context) {
215
+ return FutureBuilder (
216
+ future: Future .microtask (() => true ),
217
+ builder: (context, snapshot) {
218
+ if (! snapshot.hasData) {
219
+ return const SizedBox .shrink ();
220
+ }
221
+ return ListenableBuilder (
222
+ listenable: scrollController,
223
+ builder: (context, __) => builder (context, scrollController),
224
+ );
225
+ });
226
+ }
64
227
}
65
228
66
229
abstract class MessageActionSheetMenuItemButton extends StatelessWidget {
@@ -79,11 +242,24 @@ abstract class MessageActionSheetMenuItemButton extends StatelessWidget {
79
242
80
243
@override
81
244
Widget build (BuildContext context) {
245
+ final designVariables = DesignVariables .of (context);
82
246
final zulipLocalizations = ZulipLocalizations .of (context);
83
247
return MenuItemButton (
84
- leadingIcon: Icon (icon),
248
+ trailingIcon: Icon (icon, color: designVariables.contextMenuItemText),
249
+ style: MenuItemButton .styleFrom (
250
+ padding: const EdgeInsets .symmetric (vertical: 12 , horizontal: 16 ),
251
+ tapTargetSize: MaterialTapTargetSize .shrinkWrap,
252
+ minimumSize: Size .zero,
253
+ foregroundColor: designVariables.contextMenuItemText,
254
+ splashFactory: NoSplash .splashFactory,
255
+ ).copyWith (backgroundColor: WidgetStateColor .resolveWith ((states) =>
256
+ designVariables.contextMenuItemBg.withOpacity (
257
+ states.contains (WidgetState .pressed) ? 0.20 : 0.12 ))),
85
258
onPressed: () => onPressed (context),
86
- child: Text (label (zulipLocalizations)));
259
+ child: Text (label (zulipLocalizations),
260
+ style: const TextStyle (fontSize: 20 , height: 24 / 20 )
261
+ .merge (weightVariableTextStyle (context, wght: 600 )),
262
+ ));
87
263
}
88
264
}
89
265
@@ -96,7 +272,7 @@ class AddThumbsUpButton extends MessageActionSheetMenuItemButton {
96
272
required super .messageListContext,
97
273
});
98
274
99
- @override IconData get icon => Icons .add_reaction_outlined ;
275
+ @override IconData get icon => ZulipIcons .smile ;
100
276
101
277
@override
102
278
String label (ZulipLocalizations zulipLocalizations) {
@@ -137,11 +313,13 @@ class StarButton extends MessageActionSheetMenuItemButton {
137
313
required super .messageListContext,
138
314
});
139
315
140
- @override IconData get icon => ZulipIcons .star_filled;
316
+ @override IconData get icon => _isStarred ? ZulipIcons .star_filled : ZulipIcons .star;
317
+
318
+ bool get _isStarred => message.flags.contains (MessageFlag .starred);
141
319
142
320
@override
143
321
String label (ZulipLocalizations zulipLocalizations) {
144
- return message.flags. contains ( MessageFlag .starred)
322
+ return _isStarred
145
323
? zulipLocalizations.actionSheetOptionUnstarMessage
146
324
: zulipLocalizations.actionSheetOptionStarMessage;
147
325
}
@@ -233,7 +411,7 @@ class QuoteAndReplyButton extends MessageActionSheetMenuItemButton {
233
411
required super .messageListContext,
234
412
});
235
413
236
- @override IconData get icon => Icons .format_quote_outlined ;
414
+ @override IconData get icon => ZulipIcons .format_quote ;
237
415
238
416
@override
239
417
String label (ZulipLocalizations zulipLocalizations) {
@@ -318,7 +496,7 @@ class CopyMessageTextButton extends MessageActionSheetMenuItemButton {
318
496
required super .messageListContext,
319
497
});
320
498
321
- @override IconData get icon => Icons .copy;
499
+ @override IconData get icon => ZulipIcons .copy;
322
500
323
501
@override
324
502
String label (ZulipLocalizations zulipLocalizations) {
@@ -386,7 +564,10 @@ class ShareButton extends MessageActionSheetMenuItemButton {
386
564
required super .messageListContext,
387
565
});
388
566
389
- @override IconData get icon => Icons .adaptive.share;
567
+ @override
568
+ IconData get icon => defaultTargetPlatform == TargetPlatform .iOS
569
+ ? ZulipIcons .share_ios
570
+ : ZulipIcons .share;
390
571
391
572
@override
392
573
String label (ZulipLocalizations zulipLocalizations) {
@@ -435,3 +616,30 @@ class ShareButton extends MessageActionSheetMenuItemButton {
435
616
}
436
617
}
437
618
}
619
+
620
+ class MessageActionSheetCancelButton extends StatelessWidget {
621
+ const MessageActionSheetCancelButton ({super .key});
622
+
623
+ @override
624
+ Widget build (BuildContext context) {
625
+ final designVariables = DesignVariables .of (context);
626
+ return TextButton (
627
+ style: TextButton .styleFrom (
628
+ padding: const EdgeInsets .all (10 ),
629
+ tapTargetSize: MaterialTapTargetSize .shrinkWrap,
630
+ minimumSize: Size .zero,
631
+ foregroundColor: designVariables.contextMenuCancelText,
632
+ shape: RoundedRectangleBorder (borderRadius: BorderRadius .circular (7 )),
633
+ splashFactory: NoSplash .splashFactory,
634
+ ).copyWith (backgroundColor: WidgetStateColor .resolveWith ((states) =>
635
+ designVariables.contextMenuCancelBg.withOpacity (
636
+ states.contains (WidgetState .pressed) ? 0.20 : 0.15 ))),
637
+ onPressed: () {
638
+ Navigator .pop (context);
639
+ },
640
+ child: Text (ZulipLocalizations .of (context).dialogCancel,
641
+ style: const TextStyle (fontSize: 20 , height: 24 / 20 )
642
+ .merge (weightVariableTextStyle (context, wght: 600 ))),
643
+ );
644
+ }
645
+ }
0 commit comments