Skip to content

Commit 185b9b0

Browse files
committed
action_sheet: Redesign bottom sheet
Fixes: #90
1 parent 11c5537 commit 185b9b0

File tree

3 files changed

+325
-57
lines changed

3 files changed

+325
-57
lines changed

lib/widgets/action_sheet.dart

Lines changed: 240 additions & 44 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,51 +15,201 @@ 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)
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+
}
60213
}
61214

62215
abstract class MessageActionSheetMenuItemButton extends StatelessWidget {
@@ -75,11 +228,22 @@ abstract class MessageActionSheetMenuItemButton extends StatelessWidget {
75228

76229
@override
77230
Widget build(BuildContext context) {
231+
final designVariables = DesignVariables.of(context);
78232
final zulipLocalizations = ZulipLocalizations.of(context);
79233
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))),
81242
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+
));
83247
}
84248
}
85249

@@ -92,7 +256,7 @@ class AddThumbsUpButton extends MessageActionSheetMenuItemButton {
92256
required super.messageListContext,
93257
});
94258

95-
@override IconData get icon => Icons.add_reaction_outlined;
259+
@override IconData get icon => ZulipIcons.smile;
96260

97261
@override
98262
String label(ZulipLocalizations zulipLocalizations) {
@@ -133,11 +297,13 @@ class StarButton extends MessageActionSheetMenuItemButton {
133297
required super.messageListContext,
134298
});
135299

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

138304
@override
139305
String label(ZulipLocalizations zulipLocalizations) {
140-
return message.flags.contains(MessageFlag.starred)
306+
return _isStarred
141307
? zulipLocalizations.actionSheetOptionUnstarMessage
142308
: zulipLocalizations.actionSheetOptionStarMessage;
143309
}
@@ -229,7 +395,7 @@ class QuoteAndReplyButton extends MessageActionSheetMenuItemButton {
229395
required super.messageListContext,
230396
});
231397

232-
@override IconData get icon => Icons.format_quote_outlined;
398+
@override IconData get icon => ZulipIcons.format_quote;
233399

234400
@override
235401
String label(ZulipLocalizations zulipLocalizations) {
@@ -314,7 +480,7 @@ class CopyMessageTextButton extends MessageActionSheetMenuItemButton {
314480
required super.messageListContext,
315481
});
316482

317-
@override IconData get icon => Icons.copy;
483+
@override IconData get icon => ZulipIcons.copy;
318484

319485
@override
320486
String label(ZulipLocalizations zulipLocalizations) {
@@ -382,7 +548,10 @@ class ShareButton extends MessageActionSheetMenuItemButton {
382548
required super.messageListContext,
383549
});
384550

385-
@override IconData get icon => Icons.adaptive.share;
551+
@override
552+
IconData get icon => defaultTargetPlatform == TargetPlatform.iOS
553+
? ZulipIcons.share_ios
554+
: ZulipIcons.share;
386555

387556
@override
388557
String label(ZulipLocalizations zulipLocalizations) {
@@ -431,3 +600,30 @@ class ShareButton extends MessageActionSheetMenuItemButton {
431600
}
432601
}
433602
}
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

Comments
 (0)