diff --git a/lib/widgets/action_sheet.dart b/lib/widgets/action_sheet.dart index aa81461421..19b4890fe1 100644 --- a/lib/widgets/action_sheet.dart +++ b/lib/widgets/action_sheet.dart @@ -29,11 +29,11 @@ import 'store.dart'; import 'text.dart'; import 'theme.dart'; -void _showActionSheet( +ModalStatus _showActionSheet( BuildContext context, { required List optionButtons, }) { - showModalBottomSheet( + final future = showModalBottomSheet( context: context, // Clip.hardEdge looks bad; Clip.antiAliasWithSaveLayer looks pixel-perfect // on my iPhone 13 Pro but is marked as "much slower": @@ -63,6 +63,7 @@ void _showActionSheet( const ActionSheetCancelButton(), ]))); }); + return ModalStatus(future); } /// A button in an action sheet. @@ -464,7 +465,7 @@ class ResolveUnresolveButton extends ActionSheetMenuItemButton { /// 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}) { +ModalStatus showMessageActionSheet({required BuildContext context, required Message message}) { final pageContext = PageRoot.contextOf(context); final store = PerAccountStoreWidget.of(pageContext); @@ -492,7 +493,7 @@ void showMessageActionSheet({required BuildContext context, required Message mes ShareButton(message: message, pageContext: pageContext), ]; - _showActionSheet(pageContext, optionButtons: optionButtons); + return _showActionSheet(pageContext, optionButtons: optionButtons); } abstract class MessageActionSheetMenuItemButton extends ActionSheetMenuItemButton { diff --git a/lib/widgets/content.dart b/lib/widgets/content.dart index 5306d74a35..d47e1ad56e 100644 --- a/lib/widgets/content.dart +++ b/lib/widgets/content.dart @@ -1424,7 +1424,7 @@ class MessageTableCell extends StatelessWidget { } void _launchUrl(BuildContext context, String urlString) async { - DialogStatus showError(BuildContext context, String? message) { + ModalStatus showError(BuildContext context, String? message) { final zulipLocalizations = ZulipLocalizations.of(context); return showErrorDialog(context: context, title: zulipLocalizations.errorCouldNotOpenLinkTitle, diff --git a/lib/widgets/dialog.dart b/lib/widgets/dialog.dart index 1b1c1d4713..a94e8da21c 100644 --- a/lib/widgets/dialog.dart +++ b/lib/widgets/dialog.dart @@ -16,26 +16,28 @@ Widget _dialogActionText(String text) { ); } -/// Tracks the status of a dialog, in being still open or already closed. +/// Tracks the status of a modal dialog or modal bottom sheet, +/// in being still open or already closed. /// /// See also: -/// * [showDialog], whose return value this class is intended to wrap. -class DialogStatus { - const DialogStatus(this.closed); +/// * [showDialog] and [showModalBottomSheet], whose return values +/// this class is intended to wrap. +class ModalStatus { + const ModalStatus(this.closed); - /// Resolves when the dialog is closed. + /// Resolves when the dialog or bottom sheet is closed. final Future closed; } /// Displays an [AlertDialog] with a dismiss button. /// -/// The [DialogStatus.closed] field of the return value can be used +/// The [ModalStatus.closed] field of the return value can be used /// for waiting for the dialog to be closed. // This API is inspired by [ScaffoldManager.showSnackBar]. We wrap -// [showDialog]'s return value, a [Future], inside [DialogStatus] +// [showDialog]'s return value, a [Future], inside [ModalStatus] // whose documentation can be accessed. This helps avoid confusion when // intepreting the meaning of the [Future]. -DialogStatus showErrorDialog({ +ModalStatus showErrorDialog({ required BuildContext context, required String title, String? message, @@ -51,7 +53,7 @@ DialogStatus showErrorDialog({ onPressed: () => Navigator.pop(context), child: _dialogActionText(zulipLocalizations.errorDialogContinue)), ])); - return DialogStatus(future); + return ModalStatus(future); } void showSuggestedActionDialog({ diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index c4ca22bce3..d91f578813 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -1,6 +1,8 @@ +import 'dart:async'; import 'dart:math'; import 'package:collection/collection.dart'; +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_color_models/flutter_color_models.dart'; import 'package:intl/intl.dart' hide TextDirection; @@ -16,6 +18,7 @@ import 'actions.dart'; import 'app_bar.dart'; import 'compose_box.dart'; import 'content.dart'; +import 'dialog.dart'; import 'emoji_reaction.dart'; import 'icons.dart'; import 'page.dart'; @@ -1318,22 +1321,45 @@ String formatHeaderDate( // Design referenced from: // - https://github.com/zulip/zulip-mobile/issues/5511 // - https://www.figma.com/file/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=538%3A20849&mode=dev -class MessageWithPossibleSender extends StatelessWidget { +class MessageWithPossibleSender extends StatefulWidget { const MessageWithPossibleSender({super.key, required this.item}); final MessageListMessageItem item; + @override + State createState() => _MessageWithPossibleSenderState(); +} + +class _MessageWithPossibleSenderState extends State { + final WidgetStatesController statesController = WidgetStatesController(); + + @override + void initState() { + super.initState(); + statesController.addListener(() { + setState(() { + // Force a rebuild to resolve background color + }); + }); + } + + @override + void dispose() { + statesController.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { final store = PerAccountStoreWidget.of(context); final messageListTheme = MessageListTheme.of(context); final designVariables = DesignVariables.of(context); - final message = item.message; + final message = widget.item.message; final sender = store.getUser(message.senderId); Widget? senderRow; - if (item.showSender) { + if (widget.item.showSender) { final time = _kMessageTimestampFormat .format(DateTime.fromMillisecondsSinceEpoch(1000 * message.timestamp)); senderRow = Row( @@ -1400,40 +1426,62 @@ class MessageWithPossibleSender extends StatelessWidget { child: Icon(ZulipIcons.star_filled, size: 16, color: designVariables.star)); } - return GestureDetector( + return RawGestureDetector( behavior: HitTestBehavior.translucent, - onLongPress: () => showMessageActionSheet(context: context, message: message), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: Column(children: [ - if (senderRow != null) - Padding(padding: const EdgeInsets.fromLTRB(16, 2, 16, 0), - child: senderRow), - Row( - crossAxisAlignment: CrossAxisAlignment.baseline, - textBaseline: localizedTextBaseline(context), - children: [ - const SizedBox(width: 16), - Expanded(child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - MessageContent(message: message, content: item.content), - if ((message.reactions?.total ?? 0) > 0) - ReactionChipsList(messageId: message.id, reactions: message.reactions!), - if (editStateText != null) - Text(editStateText, - textAlign: TextAlign.end, - style: TextStyle( - color: designVariables.labelEdited, - fontSize: 12, - height: (12 / 12), - letterSpacing: proportionalLetterSpacing( - context, 0.05, baseFontSize: 12))), - ])), - SizedBox(width: 16, - child: star), - ]), - ]))); + gestures: { + LongPressGestureRecognizer: GestureRecognizerFactoryWithHandlers( + () => LongPressGestureRecognizer(duration: Duration(milliseconds: 600)), + (instance) { + instance.onLongPress = () async { + statesController.update(WidgetState.selected, true); + ModalStatus status = showMessageActionSheet(context: context, + message: message); + await status.closed; + statesController.update(WidgetState.selected, false); + }; + instance.onLongPressDown = (_) => statesController.update(WidgetState.pressed, true); + instance.onLongPressCancel = () => statesController.update(WidgetState.pressed, false); + instance.onLongPressUp = () => statesController.update(WidgetState.pressed, false); + }, + ), + }, + child: DecoratedBox(decoration: BoxDecoration( + color: WidgetStateColor.fromMap({ + WidgetState.pressed: designVariables.pressedTint, + WidgetState.selected: designVariables.pressedTint, + WidgetState.any: Colors.transparent, + }).resolve(statesController.value)), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Column(children: [ + if (senderRow != null) + Padding(padding: const EdgeInsets.fromLTRB(16, 2, 16, 0), + child: senderRow), + Row( + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: localizedTextBaseline(context), + children: [ + const SizedBox(width: 16), + Expanded(child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + MessageContent(message: message, content: widget.item.content), + if ((message.reactions?.total ?? 0) > 0) + ReactionChipsList(messageId: message.id, reactions: message.reactions!), + if (editStateText != null) + Text(editStateText, + textAlign: TextAlign.end, + style: TextStyle( + color: designVariables.labelEdited, + fontSize: 12, + height: (12 / 12), + letterSpacing: proportionalLetterSpacing( + context, 0.05, baseFontSize: 12))), + ])), + SizedBox(width: 16, + child: star), + ]), + ])))); } } diff --git a/lib/widgets/theme.dart b/lib/widgets/theme.dart index ec8ad8aecc..1dada1c39e 100644 --- a/lib/widgets/theme.dart +++ b/lib/widgets/theme.dart @@ -145,6 +145,7 @@ class DesignVariables extends ThemeExtension { labelEdited: const HSLColor.fromAHSL(0.35, 0, 0, 0).toColor(), labelMenuButton: const Color(0xff222222), mainBackground: const Color(0xfff0f0f0), + pressedTint: const HSLColor.fromAHSL(0.04, 0, 0, 0).toColor(), textInput: const Color(0xff000000), title: const Color(0xff1a1a1a), bgSearchInput: const Color(0xffe3e3e3), @@ -194,6 +195,7 @@ class DesignVariables extends ThemeExtension { labelEdited: const HSLColor.fromAHSL(0.35, 0, 0, 1).toColor(), labelMenuButton: const Color(0xffffffff).withValues(alpha: 0.85), mainBackground: const Color(0xff1d1d1d), + pressedTint: const HSLColor.fromAHSL(0.04, 0, 0, 1).toColor(), textInput: const Color(0xffffffff).withValues(alpha: 0.9), title: const Color(0xffffffff), bgSearchInput: const Color(0xff313131), @@ -251,6 +253,7 @@ class DesignVariables extends ThemeExtension { required this.labelEdited, required this.labelMenuButton, required this.mainBackground, + required this.pressedTint, required this.textInput, required this.title, required this.bgSearchInput, @@ -309,6 +312,7 @@ class DesignVariables extends ThemeExtension { final Color labelEdited; final Color labelMenuButton; final Color mainBackground; + final Color pressedTint; final Color textInput; final Color title; final Color bgSearchInput; @@ -362,6 +366,7 @@ class DesignVariables extends ThemeExtension { Color? labelEdited, Color? labelMenuButton, Color? mainBackground, + Color? pressedTint, Color? textInput, Color? title, Color? bgSearchInput, @@ -410,6 +415,7 @@ class DesignVariables extends ThemeExtension { labelEdited: labelEdited ?? this.labelEdited, labelMenuButton: labelMenuButton ?? this.labelMenuButton, mainBackground: mainBackground ?? this.mainBackground, + pressedTint: pressedTint ?? this.pressedTint, textInput: textInput ?? this.textInput, title: title ?? this.title, bgSearchInput: bgSearchInput ?? this.bgSearchInput, @@ -465,6 +471,7 @@ class DesignVariables extends ThemeExtension { labelEdited: Color.lerp(labelEdited, other.labelEdited, t)!, labelMenuButton: Color.lerp(labelMenuButton, other.labelMenuButton, t)!, mainBackground: Color.lerp(mainBackground, other.mainBackground, t)!, + pressedTint: Color.lerp(pressedTint, other.pressedTint, t)!, textInput: Color.lerp(textInput, other.textInput, t)!, title: Color.lerp(title, other.title, t)!, bgSearchInput: Color.lerp(bgSearchInput, other.bgSearchInput, t)!, diff --git a/test/widgets/message_list_test.dart b/test/widgets/message_list_test.dart index b5d3844c1a..b730f97351 100644 --- a/test/widgets/message_list_test.dart +++ b/test/widgets/message_list_test.dart @@ -26,6 +26,7 @@ import 'package:zulip/widgets/message_list.dart'; import 'package:zulip/widgets/page.dart'; import 'package:zulip/widgets/store.dart'; import 'package:zulip/widgets/channel_colors.dart'; +import 'package:zulip/widgets/theme.dart'; import '../api/fake_api.dart'; import '../example_data.dart' as eg; @@ -1314,6 +1315,68 @@ void main() { debugNetworkImageHttpClientProvider = null; }); + + group('background-color tint', () { + late Message message; + + setUp(() { + message = eg.streamMessage(); + }); + + Color? getBackgroundColor(WidgetTester tester) { + final decoratedBox = tester.widget( + find.descendant( + of: find.byType(MessageWithPossibleSender), + matching: find.byType(DecoratedBox))); + return (decoratedBox.decoration as BoxDecoration).color; + } + + testWidgets('long-press opens action sheet', (tester) async { + await setupMessageListPage(tester, messages: [message]); + + check(getBackgroundColor(tester)).equals(Colors.transparent); + + final gesture = await tester.startGesture(tester.getCenter(find.byType(MessageWithPossibleSender))); + await tester.pump(const Duration(milliseconds: 300)); + + final expectedTint = DesignVariables.of(tester.element(find.byType(MessageWithPossibleSender))) + .pressedTint; + + check(getBackgroundColor(tester)).equals(expectedTint); + + await tester.pump(const Duration(milliseconds: 300)); + + await gesture.up(); + await tester.pump(); + check(getBackgroundColor(tester)).equals(expectedTint); + + await tester.pump(const Duration(milliseconds: 250)); + + await tester.tapAt(const Offset(0, 0)); + await tester.pumpAndSettle(); + check(getBackgroundColor(tester)).equals(Colors.transparent); + }); + + testWidgets('long-press canceled', (tester) async { + await setupMessageListPage(tester, messages: [message]); + + check(getBackgroundColor(tester)).equals(Colors.transparent); + + final gesture = await tester.startGesture(tester.getCenter(find.byType(MessageWithPossibleSender))); + await tester.pump(); + + final expectedTint = DesignVariables.of(tester.element(find.byType(MessageWithPossibleSender))) + .pressedTint; + + check(getBackgroundColor(tester)).equals(expectedTint); + + await gesture.moveBy(const Offset(0, 50)); + await tester.pump(); + + check(getBackgroundColor(tester)).equals(Colors.transparent); + await gesture.up(); + }); + }); }); group('Starred messages', () {