Skip to content

Commit

Permalink
action_sheet: Redesign bottom sheet
Browse files Browse the repository at this point in the history
Fixes: #90
  • Loading branch information
sm-sayedi committed Aug 22, 2024
1 parent 11c5537 commit 185b9b0
Show file tree
Hide file tree
Showing 3 changed files with 325 additions and 57 deletions.
284 changes: 240 additions & 44 deletions lib/widgets/action_sheet.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import 'dart:math' as math;

import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_gen/gen_l10n/zulip_localizations.dart';
Expand All @@ -12,51 +15,201 @@ import 'actions.dart';
import 'clipboard.dart';
import 'compose_box.dart';
import 'dialog.dart';
import 'draggable_scrollable_modal_bottom_sheet.dart';
import 'icons.dart';
import 'message_list.dart';
import 'store.dart';
import 'text.dart';
import 'theme.dart';

/// Show a sheet of actions you can take on a message in the message list.
///
/// Must have a [MessageListPage] ancestor.
void showMessageActionSheet({required BuildContext context, required Message message}) {
final store = PerAccountStoreWidget.of(context);

// The UI that's conditioned on this won't live-update during this appearance
// of the action sheet (we avoid calling composeBoxControllerOf in a build
// method; see its doc). But currently it will be constant through the life of
// any message list, so that's fine.
final messageListPage = MessageListPage.ancestorOf(context);
final isComposeBoxOffered = messageListPage.composeBoxController != null;
final narrow = messageListPage.narrow;
final isMessageRead = message.flags.contains(MessageFlag.read);
final markAsUnreadSupported = store.connection.zulipFeatureLevel! >= 155; // TODO(server-6)
final showMarkAsUnreadButton = markAsUnreadSupported && isMessageRead;

final hasThumbsUpReactionVote = message.reactions
?.aggregated.any((reactionWithVotes) =>
reactionWithVotes.reactionType == ReactionType.unicodeEmoji
&& reactionWithVotes.emojiCode == '1f44d'
&& reactionWithVotes.userIds.contains(store.selfUserId))
?? false;

showDraggableScrollableModalBottomSheet<void>(
showModalBottomSheet<void>(
context: context,
builder: (BuildContext _) {
return Column(children: [
if (!hasThumbsUpReactionVote)
AddThumbsUpButton(message: message, messageListContext: context),
StarButton(message: message, messageListContext: context),
if (isComposeBoxOffered)
QuoteAndReplyButton(message: message, messageListContext: context),
if (showMarkAsUnreadButton)
MarkAsUnreadButton(message: message, messageListContext: context, narrow: narrow),
CopyMessageTextButton(message: message, messageListContext: context),
CopyMessageLinkButton(message: message, messageListContext: context),
ShareButton(message: message, messageListContext: context),
]);
});
useSafeArea: true,
isScrollControlled: true,
builder: (BuildContext _) => _MessageActionSheet(messageListContext: context,
message: message));
}

class _MessageActionSheet extends StatefulWidget {
const _MessageActionSheet({
required this.messageListContext,
required this.message,
});

final BuildContext messageListContext;
final Message message;

@override
State<_MessageActionSheet> createState() => _MessageActionSheetState();
}

class _MessageActionSheetState extends State<_MessageActionSheet> {
late final ScrollController scrollController = ScrollController();

@override
void dispose() {
scrollController.dispose();
super.dispose();
}

@override
Widget build(BuildContext context) {
final store = PerAccountStoreWidget.of(widget.messageListContext);

// The UI that's conditioned on this won't live-update during this appearance
// of the action sheet (we avoid calling composeBoxControllerOf in a build
// method; see its doc). But currently it will be constant through the life of
// any message list, so that's fine.
final messageListPage = MessageListPage.ancestorOf(widget.messageListContext);
final isComposeBoxOffered = messageListPage.composeBoxController != null;
final narrow = messageListPage.narrow;
final isMessageRead = widget.message.flags.contains(MessageFlag.read);
final markAsUnreadSupported = store.connection.zulipFeatureLevel! >= 155; // TODO(server-6)
final showMarkAsUnreadButton = markAsUnreadSupported && isMessageRead;

final hasThumbsUpReactionVote = widget.message.reactions
?.aggregated.any((reactionWithVotes) =>
reactionWithVotes.reactionType == ReactionType.unicodeEmoji
&& reactionWithVotes.emojiCode == '1f44d'
&& reactionWithVotes.userIds.contains(store.selfUserId))
?? false;

final optionButtons = List.filled(3, [
if (!hasThumbsUpReactionVote)
AddThumbsUpButton(message: widget.message, messageListContext: widget.messageListContext),
StarButton(message: widget.message, messageListContext: widget.messageListContext),
if (isComposeBoxOffered)
QuoteAndReplyButton(message: widget.message, messageListContext: widget.messageListContext),
if (showMarkAsUnreadButton)
MarkAsUnreadButton(message: widget.message, messageListContext: widget.messageListContext, narrow: narrow),
CopyMessageTextButton(message: widget.message, messageListContext: widget.messageListContext),
CopyMessageLinkButton(message: widget.message, messageListContext: widget.messageListContext),
ShareButton(message: widget.message, messageListContext: widget.messageListContext),
]).expand((e) => e).toList();

// Pad the bottom inset. The left/top/right insets are already handled by
// `showModalBottomSheet.useSafeArea: true` above, which keeps the sheet
// out of those insets.
return SafeArea(
minimum: const EdgeInsets.only(bottom: 16),
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
// TODO(#217): show message text
Flexible(
child: Stack(
children: [
Column(
mainAxisSize: MainAxisSize.min,
children: [
// Serves as the top dynamic padding, which changes when
// the action sheet is scrolled in the upper direction.
_ScrollControllerBuilder(
scrollController: scrollController,
builder: (_, scrollController) => SizedBox(
height: math.max(
16 - scrollController.position.extentBefore,
0,
)),
),
Flexible(child: SingleChildScrollView(
controller: scrollController,
child: ClipRRect(
borderRadius: BorderRadius.circular(7),
child: Column(spacing: 1, children: optionButtons),
)),
),
// Serves as the bottom dynamic padding, which changes when
// the action sheet is scrolled in the lower direction.
_ScrollControllerBuilder(
scrollController: scrollController,
builder: (_, __) => SizedBox(
height: math.max(
8 - scrollController.position.extentAfter,
0,
)),
),
],
),
// Serves as the top shadow:
// https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=3483-42600&t=QvIOvrQk9Rz63aKM-1
_ScrollControllerBuilder(
scrollController: scrollController,
builder: (_, scrollController) {
final designVariables = DesignVariables.of(context);
return Positioned.fill(
top: math.max(8 - scrollController.position.extentBefore, 0),
bottom: null,
child: Container(
height: math.min(scrollController.position.extentBefore, 16),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
designVariables.bgContextMenu,
designVariables.bgContextMenu.withOpacity(0),
]))));
},
),
// Serves as the bottom shadow:
// https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=3483-42291&t=VYchuPwHKwZO0Ig4-0
_ScrollControllerBuilder(
scrollController: scrollController,
builder: (_, scrollController) {
final designVariables = DesignVariables.of(context);
return Positioned.fill(
top: null,
bottom: math.max(8 - scrollController.position.extentAfter, 0),
child: Container(
height: math.min(scrollController.position.extentAfter, 8),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
designVariables.bgContextMenu.withOpacity(0),
designVariables.bgContextMenu,
]))));
},
),
],
),
),
const MessageActionSheetCancelButton(),
],
),
),
);
}
}

class _ScrollControllerBuilder extends StatelessWidget {
const _ScrollControllerBuilder({required this.scrollController, required this.builder});

final ScrollController scrollController;
final Widget Function(BuildContext, ScrollController) builder;

@override
Widget build(BuildContext context) {
return FutureBuilder(
future: Future.microtask(() => true),
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const SizedBox.shrink();
}
return ListenableBuilder(
listenable: scrollController,
builder: (context, __) => builder(context, scrollController),
);
});
}
}

abstract class MessageActionSheetMenuItemButton extends StatelessWidget {
Expand All @@ -75,11 +228,22 @@ abstract class MessageActionSheetMenuItemButton extends StatelessWidget {

@override
Widget build(BuildContext context) {
final designVariables = DesignVariables.of(context);
final zulipLocalizations = ZulipLocalizations.of(context);
return MenuItemButton(
leadingIcon: Icon(icon),
trailingIcon: Icon(icon, color: designVariables.contextMenuItemText),
style: MenuItemButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
foregroundColor: designVariables.contextMenuItemText,
splashFactory: NoSplash.splashFactory,
).copyWith(backgroundColor: WidgetStateColor.resolveWith((states) =>
designVariables.contextMenuItemBg.withOpacity(
states.contains(WidgetState.pressed) ? 0.20 : 0.12))),
onPressed: () => onPressed(context),
child: Text(label(zulipLocalizations)));
child: Text(label(zulipLocalizations),
style: const TextStyle(fontSize: 20, height: 24 / 20)
.merge(weightVariableTextStyle(context, wght: 600)),
));
}
}

Expand All @@ -92,7 +256,7 @@ class AddThumbsUpButton extends MessageActionSheetMenuItemButton {
required super.messageListContext,
});

@override IconData get icon => Icons.add_reaction_outlined;
@override IconData get icon => ZulipIcons.smile;

@override
String label(ZulipLocalizations zulipLocalizations) {
Expand Down Expand Up @@ -133,11 +297,13 @@ class StarButton extends MessageActionSheetMenuItemButton {
required super.messageListContext,
});

@override IconData get icon => ZulipIcons.star_filled;
@override IconData get icon => _isStarred ? ZulipIcons.star_filled : ZulipIcons.star;

bool get _isStarred => message.flags.contains(MessageFlag.starred);

@override
String label(ZulipLocalizations zulipLocalizations) {
return message.flags.contains(MessageFlag.starred)
return _isStarred
? zulipLocalizations.actionSheetOptionUnstarMessage
: zulipLocalizations.actionSheetOptionStarMessage;
}
Expand Down Expand Up @@ -229,7 +395,7 @@ class QuoteAndReplyButton extends MessageActionSheetMenuItemButton {
required super.messageListContext,
});

@override IconData get icon => Icons.format_quote_outlined;
@override IconData get icon => ZulipIcons.format_quote;

@override
String label(ZulipLocalizations zulipLocalizations) {
Expand Down Expand Up @@ -314,7 +480,7 @@ class CopyMessageTextButton extends MessageActionSheetMenuItemButton {
required super.messageListContext,
});

@override IconData get icon => Icons.copy;
@override IconData get icon => ZulipIcons.copy;

@override
String label(ZulipLocalizations zulipLocalizations) {
Expand Down Expand Up @@ -382,7 +548,10 @@ class ShareButton extends MessageActionSheetMenuItemButton {
required super.messageListContext,
});

@override IconData get icon => Icons.adaptive.share;
@override
IconData get icon => defaultTargetPlatform == TargetPlatform.iOS
? ZulipIcons.share_ios
: ZulipIcons.share;

@override
String label(ZulipLocalizations zulipLocalizations) {
Expand Down Expand Up @@ -431,3 +600,30 @@ class ShareButton extends MessageActionSheetMenuItemButton {
}
}
}

class MessageActionSheetCancelButton extends StatelessWidget {
const MessageActionSheetCancelButton({super.key});

@override
Widget build(BuildContext context) {
final designVariables = DesignVariables.of(context);
return TextButton(
style: TextButton.styleFrom(
padding: const EdgeInsets.all(10),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
minimumSize: Size.zero,
foregroundColor: designVariables.contextMenuCancelText,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(7)),
splashFactory: NoSplash.splashFactory,
).copyWith(backgroundColor: WidgetStateColor.resolveWith((states) =>
designVariables.contextMenuCancelBg.withOpacity(
states.contains(WidgetState.pressed) ? 0.20 : 0.15))),
onPressed: () {
Navigator.pop(context);
},
child: Text(ZulipLocalizations.of(context).dialogCancel,
style: const TextStyle(fontSize: 20, height: 24 / 20)
.merge(weightVariableTextStyle(context, wght: 600))),
);
}
}
Loading

0 comments on commit 185b9b0

Please sign in to comment.