Skip to content

Commit

Permalink
message_list: added pressedTint feature
Browse files Browse the repository at this point in the history
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: zulip#1142
  • Loading branch information
Gaurav-Kushwaha-1225 committed Feb 11, 2025
1 parent 4fe4b2e commit 92274ad
Show file tree
Hide file tree
Showing 5 changed files with 162 additions and 42 deletions.
9 changes: 5 additions & 4 deletions lib/widgets/action_sheet.dart
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,11 @@ import 'store.dart';
import 'text.dart';
import 'theme.dart';

void _showActionSheet(
ModalStatus _showActionSheet(
BuildContext context, {
required List<Widget> optionButtons,
}) {
showModalBottomSheet<void>(
final future = showModalBottomSheet<void>(
context: context,
// Clip.hardEdge looks bad; Clip.antiAliasWithSaveLayer looks pixel-perfect
// on my iPhone 13 Pro but is marked as "much slower":
Expand Down Expand Up @@ -63,6 +63,7 @@ void _showActionSheet(
const ActionSheetCancelButton(),
])));
});
return ModalStatus(future);
}

/// A button in an action sheet.
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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 {
Expand Down
1 change: 0 additions & 1 deletion lib/widgets/content.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1319,7 +1319,6 @@ class MessageTableCell extends StatelessWidget {

void _launchUrl(BuildContext context, String urlString) async {
ModalStatus showError(BuildContext context, String? message) {
final zulipLocalizations = ZulipLocalizations.of(context);
return showErrorDialog(context: context,
title: 'Unable to open link',
message: [
Expand Down
115 changes: 78 additions & 37 deletions lib/widgets/message_list.dart
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,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';
Expand Down Expand Up @@ -1316,22 +1317,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<MessageWithPossibleSender> createState() => _MessageWithPossibleSenderState();
}

class _MessageWithPossibleSenderState extends State<MessageWithPossibleSender> {
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.users[message.senderId];

Widget? senderRow;
if (item.showSender) {
if (widget.item.showSender) {
final time = _kMessageTimestampFormat
.format(DateTime.fromMillisecondsSinceEpoch(1000 * message.timestamp));
senderRow = Row(
Expand Down Expand Up @@ -1389,40 +1413,57 @@ class MessageWithPossibleSender extends StatelessWidget {

return GestureDetector(
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: message.flags.contains(MessageFlag.starred)
? Icon(ZulipIcons.star_filled, size: 16, color: designVariables.star)
: null),
]),
])));
onLongPress: () async {
statesController.update(WidgetState.selected, true);
ModalStatus status = showMessageActionSheet(context: context,
message: message);
await status.closed;
statesController.update(WidgetState.selected, false);
},
onLongPressDown: (_) => statesController.update(WidgetState.pressed, true),
onLongPressCancel: () => statesController.update(WidgetState.pressed, false),
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: message.flags.contains(MessageFlag.starred)
? Icon(ZulipIcons.star_filled, size: 16, color: designVariables.star)
: null),
]),
]))));
}
}

Expand Down
7 changes: 7 additions & 0 deletions lib/widgets/theme.dart
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ class DesignVariables extends ThemeExtension<DesignVariables> {
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),
Expand Down Expand Up @@ -194,6 +195,7 @@ class DesignVariables extends ThemeExtension<DesignVariables> {
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),
Expand Down Expand Up @@ -251,6 +253,7 @@ class DesignVariables extends ThemeExtension<DesignVariables> {
required this.labelEdited,
required this.labelMenuButton,
required this.mainBackground,
required this.pressedTint,
required this.textInput,
required this.title,
required this.bgSearchInput,
Expand Down Expand Up @@ -309,6 +312,7 @@ class DesignVariables extends ThemeExtension<DesignVariables> {
final Color labelEdited;
final Color labelMenuButton;
final Color mainBackground;
final Color pressedTint;
final Color textInput;
final Color title;
final Color bgSearchInput;
Expand Down Expand Up @@ -362,6 +366,7 @@ class DesignVariables extends ThemeExtension<DesignVariables> {
Color? labelEdited,
Color? labelMenuButton,
Color? mainBackground,
Color? pressedTint,
Color? textInput,
Color? title,
Color? bgSearchInput,
Expand Down Expand Up @@ -410,6 +415,7 @@ class DesignVariables extends ThemeExtension<DesignVariables> {
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,
Expand Down Expand Up @@ -465,6 +471,7 @@ class DesignVariables extends ThemeExtension<DesignVariables> {
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)!,
Expand Down
72 changes: 72 additions & 0 deletions test/widgets/message_list_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,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;
Expand Down Expand Up @@ -1188,6 +1189,77 @@ void main() {

debugNetworkImageHttpClientProvider = null;
});


group('action sheet visual feedback', () {
late Message message;

setUp(() {
message = eg.streamMessage();
});

Color? getBackgroundColor(WidgetTester tester) {
final decoratedBox = tester.widget<DecoratedBox>(
find.descendant(
of: find.byType(MessageWithPossibleSender),
matching: find.byType(DecoratedBox),
),
);
return (decoratedBox.decoration as BoxDecoration).color;
}

testWidgets('starts with transparent background', (tester) async {
await setupMessageListPage(tester, messages: [message]);

check(getBackgroundColor(tester),
because: 'Message should start with transparent background',
).equals(Colors.transparent);
});

testWidgets('shows tint color when long pressed', (tester) async {
await setupMessageListPage(tester, messages: [message]);

await tester.longPress(find.byType(MessageWithPossibleSender));
await tester.pump();

final expectedTint = DesignVariables.of(tester.element(find.byType(MessageWithPossibleSender)))
.pressedTint;

check(getBackgroundColor(tester),
because: 'Message should show tint color during long press',
).equals(expectedTint);
});

testWidgets('returns to transparent after action sheet dismissal', (tester) async {
await setupMessageListPage(tester, messages: [message]);

await tester.longPress(find.byType(MessageWithPossibleSender));
await tester.pump();

await tester.tapAt(const Offset(0, 0));
await tester.pumpAndSettle();

check(getBackgroundColor(tester),
because: 'Message should return to transparent after dismissal',
).equals(Colors.transparent);
});

testWidgets('maintains tint color while action sheet is open', (tester) async {
await setupMessageListPage(tester, messages: [message]);

await tester.longPress(find.byType(MessageWithPossibleSender));
await tester.pump();

final expectedTint = DesignVariables.of(tester.element(find.byType(MessageWithPossibleSender)))
.pressedTint;

await tester.pump(const Duration(milliseconds: 500));

check(getBackgroundColor(tester),
because: 'Message should continue to show tint color while action sheet is visible',
).equals(expectedTint);
});
});
});

group('Starred messages', () {
Expand Down

0 comments on commit 92274ad

Please sign in to comment.