Skip to content

Commit c2925e4

Browse files
committed
action_sheet: Redesign bottom sheet
Fixes: zulip#90
1 parent 2a5e0c0 commit c2925e4

File tree

3 files changed

+341
-60
lines changed

3 files changed

+341
-60
lines changed

lib/widgets/action_sheet.dart

Lines changed: 255 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import 'dart:math' as math;
2+
3+
import 'package:flutter/foundation.dart';
14
import 'package:flutter/material.dart';
25
import 'package:flutter/services.dart';
36
import 'package:flutter_gen/gen_l10n/zulip_localizations.dart';
@@ -12,55 +15,215 @@ import 'actions.dart';
1215
import 'clipboard.dart';
1316
import 'compose_box.dart';
1417
import 'dialog.dart';
15-
import 'draggable_scrollable_modal_bottom_sheet.dart';
1618
import 'icons.dart';
1719
import 'message_list.dart';
1820
import 'store.dart';
21+
import 'text.dart';
22+
import 'theme.dart';
1923

2024
/// Show a sheet of actions you can take on a message in the message list.
2125
///
2226
/// Must have a [MessageListPage] ancestor.
2327
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>(
4529
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+
],
58201
),
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+
}
64227
}
65228

66229
abstract class MessageActionSheetMenuItemButton extends StatelessWidget {
@@ -79,11 +242,24 @@ abstract class MessageActionSheetMenuItemButton extends StatelessWidget {
79242

80243
@override
81244
Widget build(BuildContext context) {
245+
final designVariables = DesignVariables.of(context);
82246
final zulipLocalizations = ZulipLocalizations.of(context);
83247
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))),
85258
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+
));
87263
}
88264
}
89265

@@ -96,7 +272,7 @@ class AddThumbsUpButton extends MessageActionSheetMenuItemButton {
96272
required super.messageListContext,
97273
});
98274

99-
@override IconData get icon => Icons.add_reaction_outlined;
275+
@override IconData get icon => ZulipIcons.smile;
100276

101277
@override
102278
String label(ZulipLocalizations zulipLocalizations) {
@@ -137,11 +313,13 @@ class StarButton extends MessageActionSheetMenuItemButton {
137313
required super.messageListContext,
138314
});
139315

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);
141319

142320
@override
143321
String label(ZulipLocalizations zulipLocalizations) {
144-
return message.flags.contains(MessageFlag.starred)
322+
return _isStarred
145323
? zulipLocalizations.actionSheetOptionUnstarMessage
146324
: zulipLocalizations.actionSheetOptionStarMessage;
147325
}
@@ -233,7 +411,7 @@ class QuoteAndReplyButton extends MessageActionSheetMenuItemButton {
233411
required super.messageListContext,
234412
});
235413

236-
@override IconData get icon => Icons.format_quote_outlined;
414+
@override IconData get icon => ZulipIcons.format_quote;
237415

238416
@override
239417
String label(ZulipLocalizations zulipLocalizations) {
@@ -318,7 +496,7 @@ class CopyMessageTextButton extends MessageActionSheetMenuItemButton {
318496
required super.messageListContext,
319497
});
320498

321-
@override IconData get icon => Icons.copy;
499+
@override IconData get icon => ZulipIcons.copy;
322500

323501
@override
324502
String label(ZulipLocalizations zulipLocalizations) {
@@ -386,7 +564,10 @@ class ShareButton extends MessageActionSheetMenuItemButton {
386564
required super.messageListContext,
387565
});
388566

389-
@override IconData get icon => Icons.adaptive.share;
567+
@override
568+
IconData get icon => defaultTargetPlatform == TargetPlatform.iOS
569+
? ZulipIcons.share_ios
570+
: ZulipIcons.share;
390571

391572
@override
392573
String label(ZulipLocalizations zulipLocalizations) {
@@ -435,3 +616,30 @@ class ShareButton extends MessageActionSheetMenuItemButton {
435616
}
436617
}
437618
}
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

Comments
 (0)