Skip to content

Commit 602fe9b

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. Fixes zulip#616. Partially addresses zulip#171. Signed-off-by: Zixuan James Li <[email protected]>
1 parent db74c0d commit 602fe9b

File tree

5 files changed

+179
-27
lines changed

5 files changed

+179
-27
lines changed

assets/l10n/app_en.arb

+8
Original file line numberDiff line numberDiff line change
@@ -479,5 +479,13 @@
479479
"senderFullName": {"type": "String", "example": "Alice"},
480480
"numOthers": {"type": "int", "example": "4"}
481481
}
482+
},
483+
"messageIsEdited": "Edited",
484+
"@messageIsEdited": {
485+
"description": "Text that appears on a marker next to an edited message."
486+
},
487+
"messageIsMoved": "Moved",
488+
"@messageIsMoved": {
489+
"description": "Text that appears on a marker next to a moved message."
482490
}
483491
}

lib/widgets/message_list.dart

+11-20
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 'swipable_message_row.dart';
2425
import 'text.dart';
2526
import 'theme.dart';
2627

@@ -899,7 +900,6 @@ class MessageWithPossibleSender extends StatelessWidget {
899900
@override
900901
Widget build(BuildContext context) {
901902
final store = PerAccountStoreWidget.of(context);
902-
final theme = DesignVariables.of(context);
903903

904904
final message = item.message;
905905
final sender = store.users[message.senderId];
@@ -962,25 +962,16 @@ class MessageWithPossibleSender extends StatelessWidget {
962962
if (senderRow != null)
963963
Padding(padding: const EdgeInsets.fromLTRB(16, 2, 16, 0),
964964
child: senderRow),
965-
Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
966-
const SizedBox(width: 16),
967-
Expanded(
968-
child: Column(
969-
crossAxisAlignment: CrossAxisAlignment.stretch,
970-
children: [
971-
MessageContent(message: message, content: item.content),
972-
if ((message.reactions?.total ?? 0) > 0)
973-
ReactionChipsList(messageId: message.id, reactions: message.reactions!)
974-
])),
975-
SizedBox(width: 16,
976-
child: message.flags.contains(MessageFlag.starred)
977-
// TODO(#157): fix how star marker aligns with message content
978-
// Design from Figma at:
979-
// https://www.figma.com/file/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=813%3A28817&mode=dev .
980-
? Padding(padding: const EdgeInsets.only(top: 4),
981-
child: Icon(ZulipIcons.star_filled, size: 16, color: theme.starColor))
982-
: null),
983-
]),
965+
SwipableMessageRow(
966+
message: message,
967+
child: Column(
968+
crossAxisAlignment: CrossAxisAlignment.stretch,
969+
children: [
970+
MessageContent(message: message, content: item.content),
971+
if ((message.reactions?.total ?? 0) > 0)
972+
ReactionChipsList(messageId: message.id, reactions: message.reactions!)
973+
])
974+
),
984975
])));
985976
}
986977
}

lib/widgets/swipable_message_row.dart

+126
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import 'package:flutter/material.dart';
2+
import 'package:flutter/rendering.dart';
3+
import 'package:flutter_gen/gen_l10n/zulip_localizations.dart';
4+
5+
import '../api/model/model.dart';
6+
import 'icons.dart';
7+
import 'text.dart';
8+
9+
class SwipableMessageRow extends StatefulWidget {
10+
const SwipableMessageRow({
11+
super.key,
12+
required this.child,
13+
required this.message,
14+
});
15+
16+
final Widget child;
17+
final Message message;
18+
19+
@override
20+
State<StatefulWidget> createState() => _SwipableMessageRowState();
21+
}
22+
23+
class _SwipableMessageRowState extends State<SwipableMessageRow> {
24+
@override
25+
Widget build(BuildContext context) {
26+
final hasMarker = widget.message.editState != MessageEditState.none;
27+
const starColor = HSLColor.fromAHSL(0.5, 47, 1, 0.41);
28+
29+
return LayoutBuilder(
30+
builder: (context, constraints) => Row(
31+
crossAxisAlignment: CrossAxisAlignment.baseline,
32+
textBaseline: localizedTextBaseline(context),
33+
children: [
34+
hasMarker
35+
? EditStateMarker(editState: widget.message.editState)
36+
: const SizedBox(width: 16),
37+
SizedBox(width: constraints.maxWidth - 32, child: widget.child),
38+
SizedBox(width: 16,
39+
child: widget.message.flags.contains(MessageFlag.starred)
40+
? Icon(ZulipIcons.star_filled, size: 16, color: starColor.toColor())
41+
: null),
42+
],
43+
),
44+
);
45+
}
46+
}
47+
48+
class EditStateMarker extends StatelessWidget {
49+
/// The minimum width of the marker.
50+
///
51+
/// Currently, only the collapsed state of the marker has been implemented,
52+
/// where only the marker icon, not the marker text, is visible.
53+
static const double widthCollapsed = 16;
54+
55+
const EditStateMarker({
56+
super.key,
57+
required MessageEditState editState,
58+
}) : _editState = editState;
59+
60+
final MessageEditState _editState;
61+
62+
@override
63+
Widget build(BuildContext context) {
64+
final zulipLocalizations = ZulipLocalizations.of(context);
65+
66+
final IconData icon;
67+
final double iconSize;
68+
final String markerText;
69+
70+
switch (_editState) {
71+
case MessageEditState.none:
72+
return const SizedBox(width: widthCollapsed);
73+
case MessageEditState.edited:
74+
icon = ZulipIcons.edited;
75+
iconSize = 14;
76+
markerText = zulipLocalizations.messageIsEdited;
77+
break;
78+
case MessageEditState.moved:
79+
icon = ZulipIcons.message_moved;
80+
iconSize = 8;
81+
markerText = zulipLocalizations.messageIsMoved;
82+
break;
83+
}
84+
85+
const bgMarker = Color(0xffddecf6);
86+
const textMarker = Color(0xff26516e);
87+
const textMarkerLight = Color(0xff92a7b6);
88+
89+
var marker = Row(
90+
mainAxisAlignment: MainAxisAlignment.end,
91+
mainAxisSize: MainAxisSize.min,
92+
children: [
93+
Flexible(
94+
fit: FlexFit.loose,
95+
// For now, [markerText] is not displayed because it is transparent and
96+
// there is not enough space in the parent ConstrainedBox.
97+
child: Text('$markerText ',
98+
overflow: TextOverflow.clip,
99+
softWrap: false,
100+
textAlign: TextAlign.center,
101+
style: TextStyle(fontSize: 15, color: textMarker.withAlpha(0)))),
102+
// To match the Figma design, we cannot make the collapsed width of the
103+
// marker larger. We need to explicitly allow the icon to overflow.
104+
OverflowBox(
105+
fit: OverflowBoxFit.deferToChild,
106+
maxWidth: 10,
107+
child: Icon(icon, size: iconSize, color: textMarkerLight),
108+
),
109+
],
110+
);
111+
112+
return ConstrainedBox(
113+
constraints: const BoxConstraints(maxWidth: widthCollapsed),
114+
child: Container(
115+
margin: const EdgeInsets.only(top: 4, left: 0),
116+
clipBehavior: Clip.hardEdge,
117+
decoration: BoxDecoration(
118+
borderRadius: BorderRadius.circular(2),
119+
color: bgMarker.withAlpha(0)),
120+
child: Padding(
121+
padding: const EdgeInsets.all(1.0),
122+
child: marker),
123+
),
124+
);
125+
}
126+
}

lib/widgets/theme.dart

+1-7
Original file line numberDiff line numberDiff line change
@@ -81,8 +81,7 @@ class DesignVariables extends ThemeExtension<DesignVariables> {
8181
borderBar = const Color(0x33000000),
8282
icon = const Color(0xff666699),
8383
title = const Color(0xff1a1a1a),
84-
streamColorSwatches = StreamColorSwatches.light,
85-
starColor = const HSLColor.fromAHSL(0.5, 47, 1, 0.41).toColor();
84+
streamColorSwatches = StreamColorSwatches.light;
8685

8786
DesignVariables._({
8887
required this.bgMain,
@@ -91,7 +90,6 @@ class DesignVariables extends ThemeExtension<DesignVariables> {
9190
required this.icon,
9291
required this.title,
9392
required this.streamColorSwatches,
94-
required this.starColor,
9593
});
9694

9795
/// The [DesignVariables] from the context's active theme.
@@ -112,7 +110,6 @@ class DesignVariables extends ThemeExtension<DesignVariables> {
112110

113111
// Not exactly from the Figma design, but from Vlad anyway.
114112
final StreamColorSwatches streamColorSwatches;
115-
final Color starColor;
116113

117114
@override
118115
DesignVariables copyWith({
@@ -122,7 +119,6 @@ class DesignVariables extends ThemeExtension<DesignVariables> {
122119
Color? icon,
123120
Color? title,
124121
StreamColorSwatches? streamColorSwatches,
125-
Color? starColor,
126122
}) {
127123
return DesignVariables._(
128124
bgMain: bgMain ?? this.bgMain,
@@ -131,7 +127,6 @@ class DesignVariables extends ThemeExtension<DesignVariables> {
131127
icon: icon ?? this.icon,
132128
title: title ?? this.title,
133129
streamColorSwatches: streamColorSwatches ?? this.streamColorSwatches,
134-
starColor: starColor ?? this.starColor,
135130
);
136131
}
137132

@@ -147,7 +142,6 @@ class DesignVariables extends ThemeExtension<DesignVariables> {
147142
icon: Color.lerp(icon, other.icon, t)!,
148143
title: Color.lerp(title, other.title, t)!,
149144
streamColorSwatches: StreamColorSwatches.lerp(streamColorSwatches, other.streamColorSwatches, t),
150-
starColor: Color.lerp(starColor, other.starColor, t)!,
151145
);
152146
}
153147
}

test/widgets/message_list_test.dart

+33
Original file line numberDiff line numberDiff line change
@@ -576,6 +576,39 @@ void main() {
576576
});
577577
});
578578

579+
group('EditStateMarker', () {
580+
void checkMarkersCount({required int edited, required int moved}) {
581+
check(find.byIcon(ZulipIcons.edited).evaluate()).length.equals(edited);
582+
check(find.byIcon(ZulipIcons.message_moved).evaluate()).length.equals(moved);
583+
}
584+
585+
testWidgets('no edited or moved messages', (tester) async {
586+
final message = eg.streamMessage();
587+
await setupMessageListPage(tester, messages: [message]);
588+
checkMarkersCount(edited: 0, moved: 0);
589+
});
590+
591+
testWidgets('edited and moved messages from events', (tester) async {
592+
final message = eg.streamMessage();
593+
final message2 = eg.streamMessage();
594+
await setupMessageListPage(tester, messages: [message, message2]);
595+
checkMarkersCount(edited: 0, moved: 0);
596+
597+
await store.handleEvent(eg.updateMessageEditEvent(message, renderedContent: "edited"));
598+
await tester.pump();
599+
checkMarkersCount(edited: 1, moved: 0);
600+
601+
await store.handleEvent(eg.updateMessageMoveEvent(
602+
[message, message2], origTopic: 'old', newTopic: 'new'));
603+
await tester.pump();
604+
checkMarkersCount(edited: 1, moved: 1);
605+
606+
await store.handleEvent(eg.updateMessageEditEvent(message2, renderedContent: "edited"));
607+
await tester.pump();
608+
checkMarkersCount(edited: 2, moved: 0);
609+
});
610+
});
611+
579612
group('_UnreadMarker animations', () {
580613
// TODO: Improve animation state testing so it is less tied to
581614
// implementation details and more focused on output, see:

0 commit comments

Comments
 (0)