Skip to content

Commit 804a9a8

Browse files
committed
ui: Add edited/moved marker.
This adds basic support to displaying a edited/moved marker next to the message content. As of now this is implemented without the swipe gesture control that would expand the marker. Partially addresses zulip#171. Signed-off-by: Zixuan James Li <[email protected]>
1 parent d954b9b commit 804a9a8

File tree

4 files changed

+179
-12
lines changed

4 files changed

+179
-12
lines changed

lib/widgets/edit_state_marker.dart

+67
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import 'package:flutter/material.dart';
2+
3+
import '../api/model/model.dart';
4+
import 'icons.dart';
5+
import 'text.dart';
6+
import 'theme.dart';
7+
8+
class EditStateMarker extends StatelessWidget {
9+
const EditStateMarker({
10+
super.key,
11+
required this.message,
12+
required this.children,
13+
});
14+
15+
final Message message;
16+
final List<Widget> children;
17+
18+
@override
19+
Widget build(BuildContext context) {
20+
final hasMarker = message.editState != MessageEditState.none;
21+
22+
return Row(
23+
crossAxisAlignment: CrossAxisAlignment.baseline,
24+
textBaseline: localizedTextBaseline(context),
25+
children: [
26+
hasMarker
27+
? _EditStateMarkerPill(editState: message.editState)
28+
: const SizedBox(width: _EditStateMarkerPill.widthCollapsed),
29+
...children,
30+
],
31+
);
32+
}
33+
}
34+
35+
class _EditStateMarkerPill extends StatelessWidget {
36+
const _EditStateMarkerPill({required this.editState});
37+
38+
final MessageEditState editState;
39+
40+
/// The minimum width of the marker.
41+
// Currently, only the collapsed state of the marker has been implemented,
42+
// where only the marker icon, not the marker text, is visible.
43+
static const double widthCollapsed = 16;
44+
45+
@override
46+
Widget build(BuildContext context) {
47+
final designVariables = DesignVariables.of(context);
48+
49+
final IconData icon;
50+
switch (editState) {
51+
case MessageEditState.none:
52+
assert(false);
53+
return const SizedBox(width: widthCollapsed);
54+
case MessageEditState.edited:
55+
icon = ZulipIcons.edited;
56+
case MessageEditState.moved:
57+
icon = ZulipIcons.message_moved;
58+
}
59+
60+
return ConstrainedBox(
61+
constraints: const BoxConstraints(maxWidth: widthCollapsed),
62+
child: Padding(
63+
padding: const EdgeInsetsDirectional.only(start: 2),
64+
child: Icon(icon, size: 14,
65+
color: designVariables.editedMovedMarkerCollapsed)));
66+
}
67+
}

lib/widgets/message_list.dart

+10-11
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import 'page.dart';
2121
import 'profile.dart';
2222
import 'sticky_header.dart';
2323
import 'store.dart';
24+
import 'edit_state_marker.dart';
2425
import 'text.dart';
2526
import 'theme.dart';
2627

@@ -962,18 +963,16 @@ class MessageWithPossibleSender extends StatelessWidget {
962963
if (senderRow != null)
963964
Padding(padding: const EdgeInsets.fromLTRB(16, 2, 16, 0),
964965
child: senderRow),
965-
Row(crossAxisAlignment: CrossAxisAlignment.baseline,
966-
textBaseline: localizedTextBaseline(context),
966+
EditStateMarker(
967+
message: message,
967968
children: [
968-
const SizedBox(width: 16),
969-
Expanded(
970-
child: Column(
971-
crossAxisAlignment: CrossAxisAlignment.stretch,
972-
children: [
973-
MessageContent(message: message, content: item.content),
974-
if ((message.reactions?.total ?? 0) > 0)
975-
ReactionChipsList(messageId: message.id, reactions: message.reactions!)
976-
])),
969+
Expanded(child: Column(
970+
crossAxisAlignment: CrossAxisAlignment.stretch,
971+
children: [
972+
MessageContent(message: message, content: item.content),
973+
if ((message.reactions?.total ?? 0) > 0)
974+
ReactionChipsList(messageId: message.id, reactions: message.reactions!)
975+
])),
977976
SizedBox(width: 16,
978977
child: message.flags.contains(MessageFlag.starred)
979978
? Icon(ZulipIcons.star_filled, size: 16, color: designVariables.star)

lib/widgets/theme.dart

+8-1
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,9 @@ class DesignVariables extends ThemeExtension<DesignVariables> {
8383
title = const Color(0xff1a1a1a),
8484
streamColorSwatches = StreamColorSwatches.light,
8585
// TODO(#95) unchanged in dark theme?
86-
star = const HSLColor.fromAHSL(0.5, 47, 1, 0.41).toColor();
86+
star = const HSLColor.fromAHSL(0.5, 47, 1, 0.41).toColor(),
87+
// TODO(#95) need dark-theme color
88+
editedMovedMarkerCollapsed = const Color.fromARGB(128, 146, 167, 182);
8789

8890
DesignVariables._({
8991
required this.bgMain,
@@ -93,6 +95,7 @@ class DesignVariables extends ThemeExtension<DesignVariables> {
9395
required this.title,
9496
required this.streamColorSwatches,
9597
required this.star,
98+
required this.editedMovedMarkerCollapsed,
9699
});
97100

98101
/// The [DesignVariables] from the context's active theme.
@@ -116,6 +119,7 @@ class DesignVariables extends ThemeExtension<DesignVariables> {
116119

117120
// Not named variables in Figma; taken from older Figma drafts, or elsewhere.
118121
final Color star;
122+
final Color editedMovedMarkerCollapsed;
119123

120124
@override
121125
DesignVariables copyWith({
@@ -126,6 +130,7 @@ class DesignVariables extends ThemeExtension<DesignVariables> {
126130
Color? title,
127131
StreamColorSwatches? streamColorSwatches,
128132
Color? star,
133+
Color? editedMovedMarkerCollapsed,
129134
}) {
130135
return DesignVariables._(
131136
bgMain: bgMain ?? this.bgMain,
@@ -135,6 +140,7 @@ class DesignVariables extends ThemeExtension<DesignVariables> {
135140
title: title ?? this.title,
136141
streamColorSwatches: streamColorSwatches ?? this.streamColorSwatches,
137142
star: star ?? this.star,
143+
editedMovedMarkerCollapsed: editedMovedMarkerCollapsed ?? this.editedMovedMarkerCollapsed,
138144
);
139145
}
140146

@@ -151,6 +157,7 @@ class DesignVariables extends ThemeExtension<DesignVariables> {
151157
title: Color.lerp(title, other.title, t)!,
152158
streamColorSwatches: StreamColorSwatches.lerp(streamColorSwatches, other.streamColorSwatches, t),
153159
star: Color.lerp(star, other.star, t)!,
160+
editedMovedMarkerCollapsed: Color.lerp(editedMovedMarkerCollapsed, other.editedMovedMarkerCollapsed, t)!,
154161
);
155162
}
156163
}

test/widgets/message_list_test.dart

+94
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import 'package:zulip/model/localizations.dart';
1616
import 'package:zulip/model/narrow.dart';
1717
import 'package:zulip/model/store.dart';
1818
import 'package:zulip/widgets/content.dart';
19+
import 'package:zulip/widgets/emoji_reaction.dart';
1920
import 'package:zulip/widgets/icons.dart';
2021
import 'package:zulip/widgets/message_list.dart';
2122
import 'package:zulip/widgets/store.dart';
@@ -576,6 +577,99 @@ void main() {
576577
});
577578
});
578579

580+
group('EditStateMarker', () {
581+
void checkMarkersCount({required int edited, required int moved}) {
582+
check(find.byIcon(ZulipIcons.edited).evaluate()).length.equals(edited);
583+
check(find.byIcon(ZulipIcons.message_moved).evaluate()).length.equals(moved);
584+
}
585+
586+
testWidgets('no edited or moved messages', (WidgetTester tester) async {
587+
final message = eg.streamMessage();
588+
await setupMessageListPage(tester, messages: [message]);
589+
checkMarkersCount(edited: 0, moved: 0);
590+
});
591+
592+
testWidgets('edited and moved messages from events', (WidgetTester tester) async {
593+
final message = eg.streamMessage();
594+
final message2 = eg.streamMessage();
595+
await setupMessageListPage(tester, messages: [message, message2]);
596+
checkMarkersCount(edited: 0, moved: 0);
597+
598+
await store.handleEvent(eg.updateMessageEditEvent(message, renderedContent: 'edited'));
599+
await tester.pump();
600+
checkMarkersCount(edited: 1, moved: 0);
601+
602+
await store.handleEvent(eg.updateMessageMoveEvent(
603+
[message, message2], origTopic: 'old', newTopic: 'new'));
604+
await tester.pump();
605+
checkMarkersCount(edited: 1, moved: 1);
606+
607+
await store.handleEvent(eg.updateMessageEditEvent(message2, renderedContent: 'edited'));
608+
await tester.pump();
609+
checkMarkersCount(edited: 2, moved: 0);
610+
});
611+
612+
List<List<(String, Rect)>> captureMessageRects(
613+
WidgetTester tester,
614+
List<Message> messages,
615+
Message targetMessage,
616+
) {
617+
assert(messages.contains(targetMessage));
618+
final List<List<(String, Rect)>> result = [];
619+
for (final message in messages) {
620+
final baseFinder = find.byWidgetPredicate(
621+
(widget) => widget is MessageWithPossibleSender
622+
&& widget.item.message.id == message.id);
623+
624+
Rect getRectMatching(Finder matching) {
625+
final finder = find.descendant(
626+
of: baseFinder, matching: matching)..tryEvaluate();
627+
check(finder.found, because: '${message.content}: $matching')
628+
.length.equals(1);
629+
return tester.getRect(finder.first);
630+
}
631+
632+
result.add([
633+
('MessageWithPossibleSender', tester.getRect(baseFinder)),
634+
('MessageContent', getRectMatching(find.byType(MessageContent))),
635+
('Paragraph', getRectMatching(find.byType(Paragraph))),
636+
if (message.id == targetMessage.id) ...[
637+
('Star', getRectMatching(find.byIcon(ZulipIcons.star_filled))),
638+
('ReactionChipsList', getRectMatching(find.byType(ReactionChipsList))),
639+
],
640+
]);
641+
}
642+
return result;
643+
}
644+
645+
testWidgets('edit state updates do not affect layout', (WidgetTester tester) async {
646+
final messages = [
647+
eg.streamMessage(),
648+
eg.streamMessage(
649+
reactions: [eg.unicodeEmojiReaction, eg.realmEmojiReaction],
650+
flags: [MessageFlag.starred]),
651+
eg.streamMessage(),
652+
];
653+
final StreamMessage messageWithMarker = messages[1];
654+
await setupMessageListPage(tester, messages: messages);
655+
final rectsBefore = captureMessageRects(tester, messages, messageWithMarker);
656+
checkMarkersCount(edited: 0, moved: 0);
657+
658+
await store.handleEvent(eg.updateMessageMoveEvent(
659+
[messageWithMarker], origTopic: 'old', newTopic: messageWithMarker.topic));
660+
await tester.pump();
661+
check(captureMessageRects(tester, messages, messageWithMarker))
662+
.deepEquals(rectsBefore);
663+
checkMarkersCount(edited: 0, moved: 1);
664+
665+
await store.handleEvent(eg.updateMessageEditEvent(messageWithMarker));
666+
await tester.pump();
667+
check(captureMessageRects(tester, messages, messageWithMarker))
668+
.deepEquals(rectsBefore);
669+
checkMarkersCount(edited: 1, moved: 0);
670+
});
671+
});
672+
579673
group('_UnreadMarker animations', () {
580674
// TODO: Improve animation state testing so it is less tied to
581675
// implementation details and more focused on output, see:

0 commit comments

Comments
 (0)