From a14c640acd2dbd62899a3c22b6bcd18c481f6136 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Thu, 16 Jan 2025 16:24:13 -0800 Subject: [PATCH 1/2] dialog [nfc]: Generalize to ModalStatus, renaming DialogStatus We'll soon use this for modal bottom sheets too, aka action sheets. Arguably that could mean it belongs in some other file, not specific to dialogs nor bottom sheets. But there isn't an obvious other file for it, and I think this is as good a home as any. --- lib/widgets/content.dart | 2 +- lib/widgets/dialog.dart | 20 +++++++++++--------- 2 files changed, 12 insertions(+), 10 deletions(-) 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({ From 6b606a8c07810d88711224220cfb32b4ff6fc3c6 Mon Sep 17 00:00:00 2001 From: Gaurav-Kushwaha-1225 Date: Thu, 12 Dec 2024 22:01:05 +0530 Subject: [PATCH 2/2] message_list: added `pressedTint` feature It allows to see a tint color on the message when it is pressed. Moved `MessageWithPossibleSender` to `StatefulWidget` and used `ModalStatus` return type of `showMessageActionSheet` to check whether BottomSheet is open or not. Added `pressedTint` to `DesignVariables` for using it in `MessageWithPossibleSender`. Added tests too in `message_list_test.dart`. Fixes: #1142 --- lib/widgets/action_sheet.dart | 9 ++- lib/widgets/message_list.dart | 120 +++++++++++++++++++--------- lib/widgets/theme.dart | 7 ++ test/widgets/message_list_test.dart | 63 +++++++++++++++ 4 files changed, 159 insertions(+), 40 deletions(-) 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/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', () {