Skip to content

Commit 411e495

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 594d66a commit 411e495

File tree

5 files changed

+191
-15
lines changed

5 files changed

+191
-15
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

+12-14
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,7 @@ class MessageWithPossibleSender extends StatelessWidget {
899900
@override
900901
Widget build(BuildContext context) {
901902
final store = PerAccountStoreWidget.of(context);
902-
final theme = DesignVariables.of(context);
903+
final theme = Theme.of(context).extension<DesignVariables>()!;
903904

904905
final message = item.message;
905906
final sender = store.users[message.senderId];
@@ -962,25 +963,22 @@ 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.start, children: [
966-
const SizedBox(width: 16),
967-
Expanded(
968-
child: Column(
966+
SwipableMessageRow(
967+
message: message,
968+
children: [
969+
Expanded(child: Column(
969970
crossAxisAlignment: CrossAxisAlignment.stretch,
970971
children: [
971972
MessageContent(message: message, content: item.content),
972973
if ((message.reactions?.total ?? 0) > 0)
973974
ReactionChipsList(messageId: message.id, reactions: message.reactions!)
974975
])),
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-
]),
976+
SizedBox(width: 16,
977+
child: message.flags.contains(MessageFlag.starred)
978+
? Icon(ZulipIcons.star_filled, size: 16, color: theme.starColor)
979+
: null),
980+
],
981+
),
984982
])));
985983
}
986984
}

lib/widgets/swipable_message_row.dart

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

lib/widgets/theme.dart

+19-1
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,10 @@ class DesignVariables extends ThemeExtension<DesignVariables> {
8282
icon = const Color(0xff666699),
8383
title = const Color(0xff1a1a1a),
8484
streamColorSwatches = StreamColorSwatches.light,
85-
starColor = const HSLColor.fromAHSL(0.5, 47, 1, 0.41).toColor();
85+
starColor = const HSLColor.fromAHSL(0.5, 47, 1, 0.41).toColor(),
86+
bgMarker = const Color(0xffddecf6),
87+
textMarker = const Color(0xff26516e),
88+
textMarkerLight = const Color(0xff92a7b6);
8689

8790
DesignVariables._({
8891
required this.bgMain,
@@ -92,6 +95,9 @@ class DesignVariables extends ThemeExtension<DesignVariables> {
9295
required this.title,
9396
required this.streamColorSwatches,
9497
required this.starColor,
98+
required this.bgMarker,
99+
required this.textMarker,
100+
required this.textMarkerLight,
95101
});
96102

97103
/// The [DesignVariables] from the context's active theme.
@@ -115,6 +121,9 @@ class DesignVariables extends ThemeExtension<DesignVariables> {
115121

116122
// Not named variables in Figma; taken from older Figma drafts, or elsewhere.
117123
final Color starColor;
124+
final Color bgMarker;
125+
final Color textMarker;
126+
final Color textMarkerLight;
118127

119128
@override
120129
DesignVariables copyWith({
@@ -125,6 +134,9 @@ class DesignVariables extends ThemeExtension<DesignVariables> {
125134
Color? title,
126135
StreamColorSwatches? streamColorSwatches,
127136
Color? starColor,
137+
Color? bgMarker,
138+
Color? textMarker,
139+
Color? textMarkerLight,
128140
}) {
129141
return DesignVariables._(
130142
bgMain: bgMain ?? this.bgMain,
@@ -134,6 +146,9 @@ class DesignVariables extends ThemeExtension<DesignVariables> {
134146
title: title ?? this.title,
135147
streamColorSwatches: streamColorSwatches ?? this.streamColorSwatches,
136148
starColor: starColor ?? this.starColor,
149+
bgMarker: bgMarker ?? this.bgMarker,
150+
textMarker: textMarker ?? this.textMarker,
151+
textMarkerLight: textMarkerLight ?? this.textMarkerLight,
137152
);
138153
}
139154

@@ -150,6 +165,9 @@ class DesignVariables extends ThemeExtension<DesignVariables> {
150165
title: Color.lerp(title, other.title, t)!,
151166
streamColorSwatches: StreamColorSwatches.lerp(streamColorSwatches, other.streamColorSwatches, t),
152167
starColor: Color.lerp(starColor, other.starColor, t)!,
168+
bgMarker: Color.lerp(bgMarker, other.bgMarker, t)!,
169+
textMarker: Color.lerp(textMarker, other.textMarker, t)!,
170+
textMarkerLight: Color.lerp(textMarkerLight, other.textMarkerLight, t)!,
153171
);
154172
}
155173
}

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)