Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Message List from Same User is now Distinguishable via Surface Tint on Long Press #1152

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
2 changes: 1 addition & 1 deletion lib/widgets/content.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
20 changes: 11 additions & 9 deletions lib/widgets/dialog.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> 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,
Expand All @@ -51,7 +53,7 @@ DialogStatus showErrorDialog({
onPressed: () => Navigator.pop(context),
child: _dialogActionText(zulipLocalizations.errorDialogContinue)),
]));
return DialogStatus(future);
return ModalStatus(future);
}

void showSuggestedActionDialog({
Expand Down
120 changes: 84 additions & 36 deletions lib/widgets/message_list.dart
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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';
Expand Down Expand Up @@ -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<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.getUser(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 @@ -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: <Type, GestureRecognizerFactory>{
LongPressGestureRecognizer: GestureRecognizerFactoryWithHandlers<LongPressGestureRecognizer>(
() => 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),
]),
]))));
}
}

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
63 changes: 63 additions & 0 deletions test/widgets/message_list_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<DecoratedBox>(
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', () {
Expand Down