Skip to content

Commit b8a93b4

Browse files
committed
ui [nfc]: Support edited/moved marker.
Signed-off-by: Zixuan James Li <[email protected]>
1 parent 7ba002d commit b8a93b4

File tree

4 files changed

+246
-26
lines changed

4 files changed

+246
-26
lines changed

assets/l10n/app_en.arb

+8
Original file line numberDiff line numberDiff line change
@@ -471,5 +471,13 @@
471471
"senderFullName": {"type": "String", "example": "Alice"},
472472
"numOthers": {"type": "int", "example": "4"}
473473
}
474+
},
475+
"messageIsEdited": "Edited",
476+
"@messageIsEdited": {
477+
"description": "Text that appears on a marker next to an edited message."
478+
},
479+
"messageIsMoved": "Moved",
480+
"@messageIsMoved": {
481+
"description": "Text that appears on a marker next to an moved message."
474482
}
475483
}

lib/widgets/edit_state_marker.dart

+203
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
import 'dart:ui';
2+
3+
import 'package:flutter/material.dart';
4+
import 'package:flutter/rendering.dart';
5+
import 'package:flutter_gen/gen_l10n/zulip_localizations.dart';
6+
7+
import '../api/model/model.dart';
8+
import 'icons.dart';
9+
import 'theme.dart';
10+
11+
class SwipableMessageRow extends StatefulWidget {
12+
const SwipableMessageRow({
13+
super.key,
14+
required this.child,
15+
required this.message,
16+
this.movementDuration = const Duration(milliseconds: 200),
17+
});
18+
19+
final Duration movementDuration;
20+
final Widget child;
21+
final Message message;
22+
23+
@override
24+
State<StatefulWidget> createState() => _SwipableMessageRowState();
25+
}
26+
27+
class _SwipableMessageRowState extends State<SwipableMessageRow> with TickerProviderStateMixin {
28+
@override
29+
void initState() {
30+
super.initState();
31+
_controller = AnimationController(
32+
duration: widget.movementDuration,
33+
lowerBound: EditStateMarker.widthCollapsed,
34+
upperBound: EditStateMarker.widthExpanded,
35+
vsync: this)
36+
..addListener(() => setState((){}));
37+
_slideAnimation = Tween<Offset>(begin: const Offset(0, 0), end: const Offset(1, 0))
38+
.animate(_controller);
39+
}
40+
41+
@override
42+
void dispose() {
43+
_controller.dispose();
44+
super.dispose();
45+
}
46+
47+
late AnimationController _controller;
48+
late Animation<Offset> _slideAnimation;
49+
double get dragOffset => _controller.value - EditStateMarker.widthCollapsed;
50+
51+
void _handleDragUpdate(DragUpdateDetails details) {
52+
_controller.value += details.delta.dx;
53+
}
54+
55+
void _handleDragEnd(DragEndDetails details) {
56+
_controller.reverse();
57+
}
58+
59+
@override
60+
Widget build(BuildContext context) {
61+
final theme = Theme.of(context).extension<DesignVariables>()!;
62+
63+
// TODO(#157): fix how star marker aligns with message content
64+
// Design from Figma at:
65+
// https://www.figma.com/file/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=813%3A28817&mode=dev .
66+
var star = Padding(padding: const EdgeInsets.only(top: 5.5),
67+
child: Icon(ZulipIcons.star_filled, size: 16, color: theme.starColor));
68+
final hasMarker = widget.message.editState != MessageEditState.none;
69+
70+
final content = Stack(
71+
children: [
72+
if (hasMarker) Positioned(
73+
left: 0,
74+
child: EditStateMarker(
75+
editState: widget.message.editState,
76+
animation: _controller)),
77+
Padding(
78+
// Adding [EditStateMarker.widthCollapsed] to the right padding
79+
// cancels out the left offset applied from the initial value of
80+
// [_slideAnimation] through [Transform.translate]. This is necessary
81+
// before we can add a padding of 16 pixels for the star.
82+
padding: const EdgeInsets.only(right: EditStateMarker.widthCollapsed + 16),
83+
child: Transform.translate(
84+
offset: _slideAnimation.value,
85+
child: widget.child)),
86+
if (widget.message.flags.contains(MessageFlag.starred))
87+
Positioned(
88+
// Because _controller.value does not start from zero, we subtract
89+
// its initial value from it so the star is positioned correctly
90+
// in the beginning.
91+
right: 0 - (_controller.value - EditStateMarker.widthCollapsed),
92+
child: star),
93+
],
94+
);
95+
96+
if (!hasMarker) return content;
97+
98+
return GestureDetector(
99+
onHorizontalDragEnd: _handleDragEnd,
100+
onHorizontalDragUpdate: _handleDragUpdate,
101+
child: content,
102+
);
103+
}
104+
}
105+
106+
class EditStateMarker extends StatelessWidget {
107+
/// The minimum width of the marker.
108+
///
109+
/// This is when no drag has been performed on the message row
110+
/// where only the moved/edited icon, not the text, is visible.
111+
static const double widthCollapsed = 16;
112+
113+
/// The maximum width of the marker.
114+
///
115+
/// This is typically wider than the colored pill when the marker is fully
116+
/// expanded. At that point only the blank space to the right of the colored
117+
/// block will grow until the marker reaches this width.
118+
static const double widthExpanded = 100;
119+
120+
const EditStateMarker({
121+
super.key,
122+
required MessageEditState editState,
123+
required Animation<double> animation,
124+
}) : _editState = editState, _animation = animation;
125+
126+
final MessageEditState _editState;
127+
final Animation<double> _animation;
128+
129+
double get _animationProgress => _animation.value / widthExpanded;
130+
double get _width => _animation.value;
131+
132+
@override
133+
Widget build(BuildContext context) {
134+
final theme = DesignVariables.of(context);
135+
final zulipLocalizations = ZulipLocalizations.of(context);
136+
137+
final IconData icon;
138+
final double iconSize;
139+
final String markerText;
140+
141+
switch (_editState) {
142+
case MessageEditState.none:
143+
return const SizedBox(width: widthCollapsed);
144+
case MessageEditState.edited:
145+
icon = ZulipIcons.edited;
146+
iconSize = 14;
147+
markerText = zulipLocalizations.messageIsEdited;
148+
break;
149+
case MessageEditState.moved:
150+
icon = ZulipIcons.message_moved;
151+
iconSize = 8;
152+
markerText = zulipLocalizations.messageIsMoved;
153+
break;
154+
}
155+
156+
var marker = Row(
157+
mainAxisAlignment: MainAxisAlignment.end,
158+
mainAxisSize: MainAxisSize.min,
159+
children: [
160+
Flexible(
161+
fit: FlexFit.loose,
162+
// The trailing space serves as a padding between the marker text and
163+
// the icon without needing another element in the row.
164+
child: Text('$markerText ',
165+
overflow: TextOverflow.clip,
166+
softWrap: false,
167+
textAlign: TextAlign.center,
168+
style: TextStyle(fontSize: 15, color: Color.lerp(
169+
theme.textMarker.withAlpha(0),
170+
theme.textMarker,
171+
_animationProgress)))),
172+
// To match the Figma design, we cannot make the collapsed width of the
173+
// marker larger. We need to explicitly allow the icon to overflow.
174+
OverflowBox(
175+
fit: OverflowBoxFit.deferToChild,
176+
maxWidth: 10,
177+
child: Icon(icon, size: iconSize, color: Color.lerp(
178+
theme.textMarkerLight,
179+
theme.textMarker,
180+
_animationProgress)),
181+
),
182+
],
183+
);
184+
185+
return ConstrainedBox(
186+
constraints: BoxConstraints(maxWidth: _width),
187+
child: Container(
188+
margin: EdgeInsets.only(top: 4, left: lerpDouble(0, 10, _animationProgress)!),
189+
clipBehavior: Clip.hardEdge,
190+
decoration: BoxDecoration(
191+
borderRadius: BorderRadius.circular(2),
192+
color: Color.lerp(
193+
theme.bgMarker.withAlpha(0),
194+
theme.bgMarker,
195+
_animationProgress)),
196+
child: Padding(
197+
padding: const EdgeInsets.all(1.0),
198+
child: marker,
199+
),
200+
),
201+
);
202+
}
203+
}

lib/widgets/message_list.dart

+16-25
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import 'action_sheet.dart';
1515
import 'compose_box.dart';
1616
import 'content.dart';
1717
import 'dialog.dart';
18+
import 'edit_state_marker.dart';
1819
import 'emoji_reaction.dart';
1920
import 'icons.dart';
2021
import 'page.dart';
@@ -586,11 +587,11 @@ class MessageItem extends StatelessWidget {
586587
header: header,
587588
child: _UnreadMarker(
588589
isRead: message.flags.contains(MessageFlag.read),
589-
child: ColoredBox(
590-
color: Colors.white,
591-
child: Column(children: [
592-
MessageWithPossibleSender(item: item),
593-
if (trailingWhitespace != null && item.isLastInBlock) SizedBox(height: trailingWhitespace!),
590+
child: ColoredBox(
591+
color: Colors.white,
592+
child: Column(children: [
593+
MessageWithPossibleSender(item: item),
594+
if (trailingWhitespace != null && item.isLastInBlock) SizedBox(height: trailingWhitespace!),
594595
]))));
595596
}
596597
}
@@ -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/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.
@@ -113,6 +119,9 @@ class DesignVariables extends ThemeExtension<DesignVariables> {
113119
// Not exactly from the Figma design, but from Vlad anyway.
114120
final StreamColorSwatches streamColorSwatches;
115121
final Color starColor;
122+
final Color bgMarker;
123+
final Color textMarker;
124+
final Color textMarkerLight;
116125

117126
@override
118127
DesignVariables copyWith({
@@ -123,6 +132,9 @@ class DesignVariables extends ThemeExtension<DesignVariables> {
123132
Color? title,
124133
StreamColorSwatches? streamColorSwatches,
125134
Color? starColor,
135+
Color? bgMarker,
136+
Color? textMarker,
137+
Color? textMarkerLight,
126138
}) {
127139
return DesignVariables._(
128140
bgMain: bgMain ?? this.bgMain,
@@ -132,6 +144,9 @@ class DesignVariables extends ThemeExtension<DesignVariables> {
132144
title: title ?? this.title,
133145
streamColorSwatches: streamColorSwatches ?? this.streamColorSwatches,
134146
starColor: starColor ?? this.starColor,
147+
bgMarker: bgMarker ?? this.bgMarker,
148+
textMarker: textMarker ?? this.textMarker,
149+
textMarkerLight: textMarkerLight ?? this.textMarkerLight,
135150
);
136151
}
137152

@@ -148,6 +163,9 @@ class DesignVariables extends ThemeExtension<DesignVariables> {
148163
title: Color.lerp(title, other?.title, t)!,
149164
streamColorSwatches: streamColorSwatches.lerp(other?.streamColorSwatches, t),
150165
starColor: Color.lerp(starColor, other?.starColor, t)!,
166+
bgMarker: Color.lerp(bgMarker, other?.bgMarker, t)!,
167+
textMarker: Color.lerp(textMarker, other?.textMarker, t)!,
168+
textMarkerLight: Color.lerp(textMarkerLight, other?.textMarkerLight, t)!,
151169
);
152170
}
153171
}

0 commit comments

Comments
 (0)