Skip to content

Commit d4fc3c1

Browse files
committed
ui: Support edited/moved marker swipe animation.
This adds full support to the edited/moved marker feature by allowing the user to expand the edited/moved marker to show a helper text on a colored block in the background. The marker retracts as soon as the user releases the touch. Fixes zulip#171. Signed-off-by: Zixuan James Li <[email protected]>
1 parent 8342d61 commit d4fc3c1

File tree

3 files changed

+155
-20
lines changed

3 files changed

+155
-20
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/edit_state_marker.dart

+133-20
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
1+
import 'dart:ui';
2+
13
import 'package:flutter/material.dart';
24
import 'package:flutter/rendering.dart';
5+
import 'package:flutter_gen/gen_l10n/zulip_localizations.dart';
36

47
import '../api/model/model.dart';
58
import 'icons.dart';
69
import 'text.dart';
710
import 'theme.dart';
811

9-
class EditStateMarker extends StatelessWidget {
12+
class EditStateMarker extends StatefulWidget {
1013
const EditStateMarker({
1114
super.key,
1215
required this.message,
@@ -16,53 +19,163 @@ class EditStateMarker extends StatelessWidget {
1619
final Message message;
1720
final List<Widget> children;
1821

22+
@override
23+
State<StatefulWidget> createState() => _EditStateMarkerState();
24+
}
25+
26+
class _EditStateMarkerState extends State<EditStateMarker> with TickerProviderStateMixin {
27+
@override
28+
void initState() {
29+
super.initState();
30+
_controller = AnimationController(
31+
// The duration is only used when `_controller.reverse()` is called,
32+
// i.e.: when the drag is released and the marker gets collapsed.
33+
duration: const Duration(milliseconds: 200),
34+
lowerBound: _EditStateMarkerPill.widthCollapsed,
35+
upperBound: _EditStateMarkerPill.widthExpanded,
36+
vsync: this)
37+
..addListener(() => setState((){}));
38+
}
39+
40+
@override
41+
void dispose() {
42+
_controller.dispose();
43+
super.dispose();
44+
}
45+
46+
late AnimationController _controller;
47+
48+
void _handleDragUpdate(DragUpdateDetails details) {
49+
_controller.value += details.delta.dx;
50+
}
51+
52+
void _handleDragEnd(DragEndDetails details) {
53+
_controller.reverse();
54+
}
55+
1956
@override
2057
Widget build(BuildContext context) {
21-
final hasMarker = message.editState != MessageEditState.none;
58+
final hasMarker = widget.message.editState != MessageEditState.none;
2259

23-
return Row(
24-
crossAxisAlignment: CrossAxisAlignment.baseline,
25-
textBaseline: localizedTextBaseline(context),
26-
children: [
27-
hasMarker
28-
? _EditStateMarkerPill(editState: message.editState)
29-
: const SizedBox(width: _EditStateMarkerPill.widthCollapsed),
30-
...children,
31-
],
60+
final content = LayoutBuilder(
61+
builder: (context, constraints) => OverflowBox(
62+
fit: OverflowBoxFit.deferToChild,
63+
alignment: Alignment.topLeft,
64+
maxWidth: double.infinity,
65+
child: Row(
66+
crossAxisAlignment: CrossAxisAlignment.baseline,
67+
textBaseline: localizedTextBaseline(context),
68+
children: [
69+
hasMarker
70+
? _EditStateMarkerPill(
71+
editState: widget.message.editState,
72+
animation: _controller)
73+
: const SizedBox(width: _EditStateMarkerPill.widthCollapsed),
74+
SizedBox(
75+
width: constraints.maxWidth - _EditStateMarkerPill.widthCollapsed,
76+
child: Row(
77+
crossAxisAlignment: CrossAxisAlignment.baseline,
78+
textBaseline: localizedTextBaseline(context),
79+
children: widget.children),
80+
),
81+
])),
3282
);
33-
}
83+
84+
if (!hasMarker) return content;
85+
86+
return GestureDetector(
87+
onHorizontalDragEnd: _handleDragEnd,
88+
onHorizontalDragUpdate: _handleDragUpdate,
89+
child: content,
90+
);
91+
}
3492
}
3593

3694
class _EditStateMarkerPill extends StatelessWidget {
37-
const _EditStateMarkerPill({required this.editState});
95+
const _EditStateMarkerPill({required this.editState, required this.animation});
3896

3997
final MessageEditState editState;
98+
final Animation<double> animation;
4099

41100
/// The minimum width of the marker.
42-
// Currently, only the collapsed state of the marker has been implemented,
43-
// where only the marker icon, not the marker text, is visible.
101+
///
102+
/// This is when no drag has been performed on the message row
103+
/// where only the moved/edited icon, not the text, is visible.
44104
static const double widthCollapsed = 16;
45105

106+
/// The maximum width of the marker.
107+
///
108+
/// This is typically wider than the colored pill when the marker is fully
109+
/// expanded. At that point only the blank space to the right of the colored
110+
/// block will grow until the marker reaches this width.
111+
static const double widthExpanded = 100;
112+
113+
double get _animationProgress => (animation.value - widthCollapsed) / widthExpanded;
114+
46115
@override
47116
Widget build(BuildContext context) {
48117
final designVariables = DesignVariables.of(context);
118+
final zulipLocalizations = ZulipLocalizations.of(context);
49119

50120
final IconData icon;
121+
final String markerText;
51122
switch (editState) {
52123
case MessageEditState.none:
53124
assert(false);
54125
return const SizedBox(width: widthCollapsed);
55126
case MessageEditState.edited:
56127
icon = ZulipIcons.edited;
128+
markerText = zulipLocalizations.messageIsEdited;
129+
break;
57130
case MessageEditState.moved:
58131
icon = ZulipIcons.message_moved;
132+
markerText = zulipLocalizations.messageIsMoved;
133+
break;
59134
}
60135

136+
var marker = Row(
137+
mainAxisAlignment: MainAxisAlignment.end,
138+
mainAxisSize: MainAxisSize.min,
139+
children: [
140+
Flexible(
141+
fit: FlexFit.loose,
142+
child: Text(markerText,
143+
overflow: TextOverflow.clip,
144+
softWrap: false,
145+
textAlign: TextAlign.center,
146+
style: TextStyle(fontSize: 15, color: Color.lerp(
147+
designVariables.editedMovedMarkerExpanded.withAlpha(0),
148+
designVariables.editedMovedMarkerExpanded,
149+
_animationProgress)))),
150+
SizedBox(width: lerpDouble(0, 5, _animationProgress)),
151+
// To match the Figma design, we cannot make the collapsed width of the
152+
// marker larger. We need to explicitly allow the icon to overflow.
153+
OverflowBox(
154+
fit: OverflowBoxFit.deferToChild,
155+
maxWidth: 8,
156+
child: Icon(icon, size: 14, color: Color.lerp(
157+
designVariables.editedMovedMarkerCollapsed,
158+
designVariables.editedMovedMarkerExpanded,
159+
_animationProgress)),
160+
),
161+
],
162+
);
163+
61164
return ConstrainedBox(
62-
constraints: const BoxConstraints(maxWidth: widthCollapsed),
63-
child: Padding(
64-
padding: const EdgeInsetsDirectional.only(start: 2),
65-
child: Icon(icon, size: 14,
66-
color: designVariables.editedMovedMarkerCollapsed)));
165+
constraints: BoxConstraints(maxWidth: animation.value),
166+
child: Container(
167+
margin: EdgeInsets.only(left: lerpDouble(2, 10, _animationProgress)!, right: 0),
168+
clipBehavior: Clip.hardEdge,
169+
decoration: BoxDecoration(
170+
borderRadius: BorderRadius.circular(3),
171+
color: Color.lerp(
172+
designVariables.editedMovedMarkerBg.withAlpha(0),
173+
designVariables.editedMovedMarkerBg,
174+
_animationProgress)),
175+
child: Padding(
176+
padding: EdgeInsets.symmetric(horizontal: lerpDouble(0, 3, _animationProgress)!),
177+
child: marker),
178+
),
179+
);
67180
}
68181
}

lib/widgets/theme.dart

+14
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,10 @@ class DesignVariables extends ThemeExtension<DesignVariables> {
8585
// TODO(#95) unchanged in dark theme?
8686
star = const HSLColor.fromAHSL(0.5, 47, 1, 0.41).toColor(),
8787
// TODO(#95) need dark-theme color
88+
editedMovedMarkerBg = const Color(0xffddecf6),
89+
// TODO(#95) need dark-theme color
90+
editedMovedMarkerExpanded = const Color(0xff26516e),
91+
// TODO(#95) need dark-theme color
8892
editedMovedMarkerCollapsed = const Color.fromARGB(128, 146, 167, 182);
8993

9094
DesignVariables._({
@@ -95,6 +99,8 @@ class DesignVariables extends ThemeExtension<DesignVariables> {
9599
required this.title,
96100
required this.streamColorSwatches,
97101
required this.star,
102+
required this.editedMovedMarkerBg,
103+
required this.editedMovedMarkerExpanded,
98104
required this.editedMovedMarkerCollapsed,
99105
});
100106

@@ -119,6 +125,8 @@ class DesignVariables extends ThemeExtension<DesignVariables> {
119125

120126
// Not named variables in Figma; taken from older Figma drafts, or elsewhere.
121127
final Color star;
128+
final Color editedMovedMarkerBg;
129+
final Color editedMovedMarkerExpanded;
122130
final Color editedMovedMarkerCollapsed;
123131

124132
@override
@@ -130,6 +138,8 @@ class DesignVariables extends ThemeExtension<DesignVariables> {
130138
Color? title,
131139
StreamColorSwatches? streamColorSwatches,
132140
Color? star,
141+
Color? editedMovedMarkerBg,
142+
Color? editedMovedMarkerExpanded,
133143
Color? editedMovedMarkerCollapsed,
134144
}) {
135145
return DesignVariables._(
@@ -140,6 +150,8 @@ class DesignVariables extends ThemeExtension<DesignVariables> {
140150
title: title ?? this.title,
141151
streamColorSwatches: streamColorSwatches ?? this.streamColorSwatches,
142152
star: star ?? this.star,
153+
editedMovedMarkerBg: editedMovedMarkerBg ?? this.editedMovedMarkerBg,
154+
editedMovedMarkerExpanded: editedMovedMarkerExpanded ?? this.editedMovedMarkerExpanded,
143155
editedMovedMarkerCollapsed: editedMovedMarkerCollapsed ?? this.editedMovedMarkerCollapsed,
144156
);
145157
}
@@ -157,6 +169,8 @@ class DesignVariables extends ThemeExtension<DesignVariables> {
157169
title: Color.lerp(title, other.title, t)!,
158170
streamColorSwatches: StreamColorSwatches.lerp(streamColorSwatches, other.streamColorSwatches, t),
159171
star: Color.lerp(star, other.star, t)!,
172+
editedMovedMarkerBg: Color.lerp(editedMovedMarkerBg, other.editedMovedMarkerBg, t)!,
173+
editedMovedMarkerExpanded: Color.lerp(editedMovedMarkerExpanded, other.editedMovedMarkerExpanded, t)!,
160174
editedMovedMarkerCollapsed: Color.lerp(editedMovedMarkerCollapsed, other.editedMovedMarkerCollapsed, t)!,
161175
);
162176
}

0 commit comments

Comments
 (0)