Skip to content

Commit d4981ee

Browse files
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
1 parent 6525207 commit d4981ee

File tree

5 files changed

+166
-44
lines changed

5 files changed

+166
-44
lines changed

lib/widgets/action_sheet.dart

+5-4
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,11 @@ import 'store.dart';
2828
import 'text.dart';
2929
import 'theme.dart';
3030

31-
void _showActionSheet(
31+
ModalStatus _showActionSheet(
3232
BuildContext context, {
3333
required List<Widget> optionButtons,
3434
}) {
35-
showModalBottomSheet<void>(
35+
final future = showModalBottomSheet<void>(
3636
context: context,
3737
// Clip.hardEdge looks bad; Clip.antiAliasWithSaveLayer looks pixel-perfect
3838
// on my iPhone 13 Pro but is marked as "much slower":
@@ -62,6 +62,7 @@ void _showActionSheet(
6262
const ActionSheetCancelButton(),
6363
])));
6464
});
65+
return ModalStatus(future);
6566
}
6667

6768
/// A button in an action sheet.
@@ -375,7 +376,7 @@ class UserTopicUpdateButton extends ActionSheetMenuItemButton {
375376
/// Show a sheet of actions you can take on a message in the message list.
376377
///
377378
/// Must have a [MessageListPage] ancestor.
378-
void showMessageActionSheet({required BuildContext context, required Message message}) {
379+
ModalStatus showMessageActionSheet({required BuildContext context, required Message message}) {
379380
final store = PerAccountStoreWidget.of(context);
380381

381382
// The UI that's conditioned on this won't live-update during this appearance
@@ -402,7 +403,7 @@ void showMessageActionSheet({required BuildContext context, required Message mes
402403
ShareButton(message: message, pageContext: context),
403404
];
404405

405-
_showActionSheet(context, optionButtons: optionButtons);
406+
return _showActionSheet(context, optionButtons: optionButtons);
406407
}
407408

408409
abstract class MessageActionSheetMenuItemButton extends ActionSheetMenuItemButton {

lib/widgets/content.dart

+4-3
Original file line numberDiff line numberDiff line change
@@ -1214,7 +1214,7 @@ class GlobalTime extends StatelessWidget {
12141214
final GlobalTimeNode node;
12151215
final TextStyle ambientTextStyle;
12161216

1217-
static final _dateFormat = DateFormat('EEE, MMM d, y, h:mm a'); // TODO(intl): localize date
1217+
static final _dateFormat = DateFormat('EEE, MMM d, y, h:mm a'); // TODO(i18n): localize date
12181218

12191219
@override
12201220
Widget build(BuildContext context) {
@@ -1321,9 +1321,9 @@ void _launchUrl(BuildContext context, String urlString) async {
13211321
ModalStatus showError(BuildContext context, String? message) {
13221322
final zulipLocalizations = ZulipLocalizations.of(context);
13231323
return showErrorDialog(context: context,
1324-
title: 'Unable to open link',
1324+
title: zulipLocalizations.errorCouldNotOpenLinkTitle,
13251325
message: [
1326-
'Link could not be opened: $urlString',
1326+
zulipLocalizations.errorCouldNotOpenLink(urlString),
13271327
if (message != null) message,
13281328
].join("\n\n"));
13291329
}
@@ -1571,6 +1571,7 @@ InlineSpan _errorUnimplemented(UnimplementedNode node, {required BuildContext co
15711571
// because release mode isn't yet about general users but developer demos,
15721572
// and we want to keep the demos honest.
15731573
// TODO(#194) think through UX for general release
1574+
// TODO(#1285) translate this
15741575
final htmlNode = node.htmlNode;
15751576
if (htmlNode is dom.Element) {
15761577
return TextSpan(children: [

lib/widgets/message_list.dart

+78-37
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import 'actions.dart';
1616
import 'app_bar.dart';
1717
import 'compose_box.dart';
1818
import 'content.dart';
19+
import 'dialog.dart';
1920
import 'emoji_reaction.dart';
2021
import 'icons.dart';
2122
import 'page.dart';
@@ -1300,22 +1301,45 @@ String formatHeaderDate(
13001301
// Design referenced from:
13011302
// - https://github.com/zulip/zulip-mobile/issues/5511
13021303
// - https://www.figma.com/file/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=538%3A20849&mode=dev
1303-
class MessageWithPossibleSender extends StatelessWidget {
1304+
class MessageWithPossibleSender extends StatefulWidget {
13041305
const MessageWithPossibleSender({super.key, required this.item});
13051306

13061307
final MessageListMessageItem item;
13071308

1309+
@override
1310+
State<MessageWithPossibleSender> createState() => _MessageWithPossibleSenderState();
1311+
}
1312+
1313+
class _MessageWithPossibleSenderState extends State<MessageWithPossibleSender> {
1314+
final WidgetStatesController statesController = WidgetStatesController();
1315+
1316+
@override
1317+
void initState() {
1318+
super.initState();
1319+
statesController.addListener(() {
1320+
setState(() {
1321+
// Force a rebuild to resolve background color
1322+
});
1323+
});
1324+
}
1325+
1326+
@override
1327+
void dispose() {
1328+
statesController.dispose();
1329+
super.dispose();
1330+
}
1331+
13081332
@override
13091333
Widget build(BuildContext context) {
13101334
final store = PerAccountStoreWidget.of(context);
13111335
final messageListTheme = MessageListTheme.of(context);
13121336
final designVariables = DesignVariables.of(context);
13131337

1314-
final message = item.message;
1338+
final message = widget.item.message;
13151339
final sender = store.users[message.senderId];
13161340

13171341
Widget? senderRow;
1318-
if (item.showSender) {
1342+
if (widget.item.showSender) {
13191343
final time = _kMessageTimestampFormat
13201344
.format(DateTime.fromMillisecondsSinceEpoch(1000 * message.timestamp));
13211345
senderRow = Row(
@@ -1373,40 +1397,57 @@ class MessageWithPossibleSender extends StatelessWidget {
13731397

13741398
return GestureDetector(
13751399
behavior: HitTestBehavior.translucent,
1376-
onLongPress: () => showMessageActionSheet(context: context, message: message),
1377-
child: Padding(
1378-
padding: const EdgeInsets.symmetric(vertical: 4),
1379-
child: Column(children: [
1380-
if (senderRow != null)
1381-
Padding(padding: const EdgeInsets.fromLTRB(16, 2, 16, 0),
1382-
child: senderRow),
1383-
Row(
1384-
crossAxisAlignment: CrossAxisAlignment.baseline,
1385-
textBaseline: localizedTextBaseline(context),
1386-
children: [
1387-
const SizedBox(width: 16),
1388-
Expanded(child: Column(
1389-
crossAxisAlignment: CrossAxisAlignment.stretch,
1390-
children: [
1391-
MessageContent(message: message, content: item.content),
1392-
if ((message.reactions?.total ?? 0) > 0)
1393-
ReactionChipsList(messageId: message.id, reactions: message.reactions!),
1394-
if (editStateText != null)
1395-
Text(editStateText,
1396-
textAlign: TextAlign.end,
1397-
style: TextStyle(
1398-
color: designVariables.labelEdited,
1399-
fontSize: 12,
1400-
height: (12 / 12),
1401-
letterSpacing: proportionalLetterSpacing(
1402-
context, 0.05, baseFontSize: 12))),
1403-
])),
1404-
SizedBox(width: 16,
1405-
child: message.flags.contains(MessageFlag.starred)
1406-
? Icon(ZulipIcons.star_filled, size: 16, color: designVariables.star)
1407-
: null),
1408-
]),
1409-
])));
1400+
onLongPress: () async {
1401+
statesController.update(WidgetState.selected, true);
1402+
ModalStatus status = showMessageActionSheet(context: context,
1403+
message: message);
1404+
await status.closed;
1405+
statesController.update(WidgetState.selected, false);
1406+
},
1407+
onLongPressDown: (_) => statesController.update(WidgetState.pressed, true),
1408+
onLongPressCancel: () => statesController.update(WidgetState.pressed, false),
1409+
onLongPressUp: () => statesController.update(WidgetState.pressed, false),
1410+
child: DecoratedBox(
1411+
decoration: BoxDecoration(
1412+
color: WidgetStateColor.fromMap({
1413+
WidgetState.pressed: designVariables.pressedTint,
1414+
WidgetState.selected: designVariables.pressedTint,
1415+
WidgetState.any: Colors.transparent,
1416+
}).resolve(statesController.value)
1417+
),
1418+
child: Padding(
1419+
padding: const EdgeInsets.symmetric(vertical: 4),
1420+
child: Column(children: [
1421+
if (senderRow != null)
1422+
Padding(padding: const EdgeInsets.fromLTRB(16, 2, 16, 0),
1423+
child: senderRow),
1424+
Row(
1425+
crossAxisAlignment: CrossAxisAlignment.baseline,
1426+
textBaseline: localizedTextBaseline(context),
1427+
children: [
1428+
const SizedBox(width: 16),
1429+
Expanded(child: Column(
1430+
crossAxisAlignment: CrossAxisAlignment.stretch,
1431+
children: [
1432+
MessageContent(message: message, content: widget.item.content),
1433+
if ((message.reactions?.total ?? 0) > 0)
1434+
ReactionChipsList(messageId: message.id, reactions: message.reactions!),
1435+
if (editStateText != null)
1436+
Text(editStateText,
1437+
textAlign: TextAlign.end,
1438+
style: TextStyle(
1439+
color: designVariables.labelEdited,
1440+
fontSize: 12,
1441+
height: (12 / 12),
1442+
letterSpacing: proportionalLetterSpacing(
1443+
context, 0.05, baseFontSize: 12))),
1444+
])),
1445+
SizedBox(width: 16,
1446+
child: message.flags.contains(MessageFlag.starred)
1447+
? Icon(ZulipIcons.star_filled, size: 16, color: designVariables.star)
1448+
: null),
1449+
]),
1450+
]))));
14101451
}
14111452
}
14121453

lib/widgets/theme.dart

+7
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,7 @@ class DesignVariables extends ThemeExtension<DesignVariables> {
145145
labelEdited: const HSLColor.fromAHSL(0.35, 0, 0, 0).toColor(),
146146
labelMenuButton: const Color(0xff222222),
147147
mainBackground: const Color(0xfff0f0f0),
148+
pressedTint: const HSLColor.fromAHSL(0.04, 0, 0, 0).toColor(),
148149
textInput: const Color(0xff000000),
149150
title: const Color(0xff1a1a1a),
150151
bgSearchInput: const Color(0xffe3e3e3),
@@ -194,6 +195,7 @@ class DesignVariables extends ThemeExtension<DesignVariables> {
194195
labelEdited: const HSLColor.fromAHSL(0.35, 0, 0, 1).toColor(),
195196
labelMenuButton: const Color(0xffffffff).withValues(alpha: 0.85),
196197
mainBackground: const Color(0xff1d1d1d),
198+
pressedTint: const HSLColor.fromAHSL(0.04, 0, 0, 1).toColor(),
197199
textInput: const Color(0xffffffff).withValues(alpha: 0.9),
198200
title: const Color(0xffffffff),
199201
bgSearchInput: const Color(0xff313131),
@@ -251,6 +253,7 @@ class DesignVariables extends ThemeExtension<DesignVariables> {
251253
required this.labelEdited,
252254
required this.labelMenuButton,
253255
required this.mainBackground,
256+
required this.pressedTint,
254257
required this.textInput,
255258
required this.title,
256259
required this.bgSearchInput,
@@ -309,6 +312,7 @@ class DesignVariables extends ThemeExtension<DesignVariables> {
309312
final Color labelEdited;
310313
final Color labelMenuButton;
311314
final Color mainBackground;
315+
final Color pressedTint;
312316
final Color textInput;
313317
final Color title;
314318
final Color bgSearchInput;
@@ -362,6 +366,7 @@ class DesignVariables extends ThemeExtension<DesignVariables> {
362366
Color? labelEdited,
363367
Color? labelMenuButton,
364368
Color? mainBackground,
369+
Color? pressedTint,
365370
Color? textInput,
366371
Color? title,
367372
Color? bgSearchInput,
@@ -410,6 +415,7 @@ class DesignVariables extends ThemeExtension<DesignVariables> {
410415
labelEdited: labelEdited ?? this.labelEdited,
411416
labelMenuButton: labelMenuButton ?? this.labelMenuButton,
412417
mainBackground: mainBackground ?? this.mainBackground,
418+
pressedTint: pressedTint ?? this.pressedTint,
413419
textInput: textInput ?? this.textInput,
414420
title: title ?? this.title,
415421
bgSearchInput: bgSearchInput ?? this.bgSearchInput,
@@ -465,6 +471,7 @@ class DesignVariables extends ThemeExtension<DesignVariables> {
465471
labelEdited: Color.lerp(labelEdited, other.labelEdited, t)!,
466472
labelMenuButton: Color.lerp(labelMenuButton, other.labelMenuButton, t)!,
467473
mainBackground: Color.lerp(mainBackground, other.mainBackground, t)!,
474+
pressedTint: Color.lerp(pressedTint, other.pressedTint, t)!,
468475
textInput: Color.lerp(textInput, other.textInput, t)!,
469476
title: Color.lerp(title, other.title, t)!,
470477
bgSearchInput: Color.lerp(bgSearchInput, other.bgSearchInput, t)!,

test/widgets/message_list_test.dart

+72
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import 'package:zulip/widgets/message_list.dart';
2424
import 'package:zulip/widgets/page.dart';
2525
import 'package:zulip/widgets/store.dart';
2626
import 'package:zulip/widgets/channel_colors.dart';
27+
import 'package:zulip/widgets/theme.dart';
2728

2829
import '../api/fake_api.dart';
2930
import '../example_data.dart' as eg;
@@ -1188,6 +1189,77 @@ void main() {
11881189

11891190
debugNetworkImageHttpClientProvider = null;
11901191
});
1192+
1193+
1194+
group('action sheet visual feedback', () {
1195+
late Message message;
1196+
1197+
setUp(() {
1198+
message = eg.streamMessage();
1199+
});
1200+
1201+
Color? getBackgroundColor(WidgetTester tester) {
1202+
final decoratedBox = tester.widget<DecoratedBox>(
1203+
find.descendant(
1204+
of: find.byType(MessageWithPossibleSender),
1205+
matching: find.byType(DecoratedBox),
1206+
),
1207+
);
1208+
return (decoratedBox.decoration as BoxDecoration).color;
1209+
}
1210+
1211+
testWidgets('starts with transparent background', (tester) async {
1212+
await setupMessageListPage(tester, messages: [message]);
1213+
1214+
check(getBackgroundColor(tester),
1215+
because: 'Message should start with transparent background',
1216+
).equals(Colors.transparent);
1217+
});
1218+
1219+
testWidgets('shows tint color when long pressed', (tester) async {
1220+
await setupMessageListPage(tester, messages: [message]);
1221+
1222+
await tester.longPress(find.byType(MessageWithPossibleSender));
1223+
await tester.pump();
1224+
1225+
final expectedTint = DesignVariables.of(tester.element(find.byType(MessageWithPossibleSender)))
1226+
.pressedTint;
1227+
1228+
check(getBackgroundColor(tester),
1229+
because: 'Message should show tint color during long press',
1230+
).equals(expectedTint);
1231+
});
1232+
1233+
testWidgets('returns to transparent after action sheet dismissal', (tester) async {
1234+
await setupMessageListPage(tester, messages: [message]);
1235+
1236+
await tester.longPress(find.byType(MessageWithPossibleSender));
1237+
await tester.pump();
1238+
1239+
await tester.tapAt(const Offset(0, 0));
1240+
await tester.pumpAndSettle();
1241+
1242+
check(getBackgroundColor(tester),
1243+
because: 'Message should return to transparent after dismissal',
1244+
).equals(Colors.transparent);
1245+
});
1246+
1247+
testWidgets('maintains tint color while action sheet is open', (tester) async {
1248+
await setupMessageListPage(tester, messages: [message]);
1249+
1250+
await tester.longPress(find.byType(MessageWithPossibleSender));
1251+
await tester.pump();
1252+
1253+
final expectedTint = DesignVariables.of(tester.element(find.byType(MessageWithPossibleSender)))
1254+
.pressedTint;
1255+
1256+
await tester.pump(const Duration(milliseconds: 500));
1257+
1258+
check(getBackgroundColor(tester),
1259+
because: 'Message should continue to show tint color while action sheet is visible',
1260+
).equals(expectedTint);
1261+
});
1262+
});
11911263
});
11921264

11931265
group('Starred messages', () {

0 commit comments

Comments
 (0)