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,207 @@ 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
+ _ScrollControllerBuilder (
98
+ scrollController: scrollController,
99
+ builder: (_, scrollController) => SizedBox (
100
+ height: math.max (
101
+ 16 - scrollController.position.extentBefore,
102
+ 0 ,
103
+ )),
104
+ ),
105
+ Flexible (
106
+ child: SingleChildScrollView (
107
+ controller: scrollController,
108
+ child: ClipRRect (
109
+ borderRadius: BorderRadius .circular (7 ),
110
+ child: Column (spacing: 1 , children: [
111
+ if (! hasThumbsUpReactionVote) AddThumbsUpButton (
112
+ message: widget.message,
113
+ messageListContext: widget.messageListContext,
114
+ ),
115
+ StarButton (message: widget.message, messageListContext: widget.messageListContext),
116
+ if (isComposeBoxOffered) QuoteAndReplyButton (
117
+ message: widget.message,
118
+ messageListContext: widget.messageListContext,
119
+ ),
120
+ if (showMarkAsUnreadButton) MarkAsUnreadButton (
121
+ message: widget.message,
122
+ messageListContext: widget.messageListContext,
123
+ narrow: narrow,
124
+ ),
125
+ CopyMessageTextButton (message: widget.message, messageListContext: widget.messageListContext),
126
+ CopyMessageLinkButton (message: widget.message, messageListContext: widget.messageListContext),
127
+ ShareButton (message: widget.message, messageListContext: widget.messageListContext),
128
+ ]),
129
+ ),
130
+ ),
131
+ ),
132
+ _ScrollControllerBuilder (
133
+ scrollController: scrollController,
134
+ builder: (_, __) => SizedBox (
135
+ height: math.max (
136
+ 8 - scrollController.position.extentAfter,
137
+ 0 ,
138
+ )),
139
+ ),
140
+ ],
141
+ ),
142
+ _ScrollControllerBuilder (
143
+ scrollController: scrollController,
144
+ builder: (_, scrollController) {
145
+ final designVariables = DesignVariables .of (context);
146
+ return Positioned .fill (
147
+ top: math.max (16 - 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
+ ),
162
+ );
163
+ }
164
+ ),
165
+ _ScrollControllerBuilder (
166
+ scrollController: scrollController,
167
+ builder: (_, scrollController) {
168
+ final designVariables = DesignVariables .of (context);
169
+ return Positioned .fill (
170
+ top: null ,
171
+ bottom: math.max (8 - scrollController.position.extentAfter, 0 ),
172
+ child: Container (
173
+ height: math.min (scrollController.position.extentAfter, 8 ),
174
+ decoration: BoxDecoration (
175
+ gradient: LinearGradient (
176
+ begin: Alignment .topCenter,
177
+ end: Alignment .bottomCenter,
178
+ colors: [
179
+ designVariables.bgContextMenu.withOpacity (0 ),
180
+ designVariables.bgContextMenu,
181
+ ],
182
+ ),
183
+ ),
184
+ ),
185
+ );
186
+ }
187
+ ),
188
+ ],
189
+ ),
190
+ ),
191
+ const MessageActionSheetCancelButton (),
192
+ ],
58
193
),
59
- CopyMessageTextButton (message: message, messageListContext: context),
60
- CopyMessageLinkButton (message: message, messageListContext: context),
61
- ShareButton (message: message, messageListContext: context),
62
- ]);
63
- });
194
+ ),
195
+ );
196
+ }
197
+ }
198
+
199
+ class _ScrollControllerBuilder extends StatelessWidget {
200
+ const _ScrollControllerBuilder ({required this .scrollController, required this .builder});
201
+
202
+ final ScrollController scrollController;
203
+ final Widget Function (BuildContext , ScrollController ) builder;
204
+
205
+ @override
206
+ Widget build (BuildContext context) {
207
+ return FutureBuilder (
208
+ future: Future .microtask (() => true ),
209
+ builder: (context, snapshot) {
210
+ if (! snapshot.hasData) {
211
+ return const SizedBox .shrink ();
212
+ }
213
+ return ListenableBuilder (
214
+ listenable: scrollController,
215
+ builder: (context, __) => builder (context, scrollController),
216
+ );
217
+ });
218
+ }
64
219
}
65
220
66
221
abstract class MessageActionSheetMenuItemButton extends StatelessWidget {
@@ -79,11 +234,24 @@ abstract class MessageActionSheetMenuItemButton extends StatelessWidget {
79
234
80
235
@override
81
236
Widget build (BuildContext context) {
237
+ final designVariables = DesignVariables .of (context);
82
238
final zulipLocalizations = ZulipLocalizations .of (context);
83
239
return MenuItemButton (
84
- leadingIcon: Icon (icon),
240
+ trailingIcon: Icon (icon, color: designVariables.contextMenuItemText),
241
+ style: MenuItemButton .styleFrom (
242
+ padding: const EdgeInsets .symmetric (vertical: 12 , horizontal: 16 ),
243
+ tapTargetSize: MaterialTapTargetSize .shrinkWrap,
244
+ minimumSize: Size .zero,
245
+ foregroundColor: designVariables.contextMenuItemText,
246
+ splashFactory: NoSplash .splashFactory,
247
+ ).copyWith (backgroundColor: WidgetStateColor .resolveWith ((states) =>
248
+ designVariables.contextMenuItemBg.withOpacity (
249
+ states.contains (WidgetState .pressed) ? 0.20 : 0.12 ))),
85
250
onPressed: () => onPressed (context),
86
- child: Text (label (zulipLocalizations)));
251
+ child: Text (label (zulipLocalizations),
252
+ style: const TextStyle (fontSize: 20 , height: 24 / 20 )
253
+ .merge (weightVariableTextStyle (context, wght: 600 )),
254
+ ));
87
255
}
88
256
}
89
257
@@ -96,7 +264,7 @@ class AddThumbsUpButton extends MessageActionSheetMenuItemButton {
96
264
required super .messageListContext,
97
265
});
98
266
99
- @override IconData get icon => Icons .add_reaction_outlined ;
267
+ @override IconData get icon => ZulipIcons .smile ;
100
268
101
269
@override
102
270
String label (ZulipLocalizations zulipLocalizations) {
@@ -137,11 +305,13 @@ class StarButton extends MessageActionSheetMenuItemButton {
137
305
required super .messageListContext,
138
306
});
139
307
140
- @override IconData get icon => ZulipIcons .star_filled;
308
+ @override IconData get icon => _isStarred ? ZulipIcons .star_filled : ZulipIcons .star;
309
+
310
+ bool get _isStarred => message.flags.contains (MessageFlag .starred);
141
311
142
312
@override
143
313
String label (ZulipLocalizations zulipLocalizations) {
144
- return message.flags. contains ( MessageFlag .starred)
314
+ return _isStarred
145
315
? zulipLocalizations.actionSheetOptionUnstarMessage
146
316
: zulipLocalizations.actionSheetOptionStarMessage;
147
317
}
@@ -233,7 +403,7 @@ class QuoteAndReplyButton extends MessageActionSheetMenuItemButton {
233
403
required super .messageListContext,
234
404
});
235
405
236
- @override IconData get icon => Icons .format_quote_outlined ;
406
+ @override IconData get icon => ZulipIcons .format_quote ;
237
407
238
408
@override
239
409
String label (ZulipLocalizations zulipLocalizations) {
@@ -318,7 +488,7 @@ class CopyMessageTextButton extends MessageActionSheetMenuItemButton {
318
488
required super .messageListContext,
319
489
});
320
490
321
- @override IconData get icon => Icons .copy;
491
+ @override IconData get icon => ZulipIcons .copy;
322
492
323
493
@override
324
494
String label (ZulipLocalizations zulipLocalizations) {
@@ -386,7 +556,10 @@ class ShareButton extends MessageActionSheetMenuItemButton {
386
556
required super .messageListContext,
387
557
});
388
558
389
- @override IconData get icon => Icons .adaptive.share;
559
+ @override
560
+ IconData get icon => defaultTargetPlatform == TargetPlatform .iOS
561
+ ? ZulipIcons .share_ios
562
+ : ZulipIcons .share;
390
563
391
564
@override
392
565
String label (ZulipLocalizations zulipLocalizations) {
@@ -435,3 +608,30 @@ class ShareButton extends MessageActionSheetMenuItemButton {
435
608
}
436
609
}
437
610
}
611
+
612
+ class MessageActionSheetCancelButton extends StatelessWidget {
613
+ const MessageActionSheetCancelButton ({super .key});
614
+
615
+ @override
616
+ Widget build (BuildContext context) {
617
+ final designVariables = DesignVariables .of (context);
618
+ return TextButton (
619
+ style: TextButton .styleFrom (
620
+ padding: const EdgeInsets .all (10 ),
621
+ tapTargetSize: MaterialTapTargetSize .shrinkWrap,
622
+ minimumSize: Size .zero,
623
+ foregroundColor: designVariables.contextMenuCancelText,
624
+ shape: RoundedRectangleBorder (borderRadius: BorderRadius .circular (7 )),
625
+ splashFactory: NoSplash .splashFactory,
626
+ ).copyWith (backgroundColor: WidgetStateColor .resolveWith ((states) =>
627
+ designVariables.contextMenuCancelBg.withOpacity (
628
+ states.contains (WidgetState .pressed) ? 0.20 : 0.15 ))),
629
+ onPressed: () {
630
+ Navigator .pop (context);
631
+ },
632
+ child: Text (ZulipLocalizations .of (context).dialogCancel,
633
+ style: const TextStyle (fontSize: 20 , height: 24 / 20 )
634
+ .merge (weightVariableTextStyle (context, wght: 600 ))),
635
+ );
636
+ }
637
+ }
0 commit comments