Skip to content

Commit 92e10e5

Browse files
committed
(wip) ui [nfc]: Support edited/moved marker.
Signed-off-by: Zixuan James Li <[email protected]>
1 parent 3d869b8 commit 92e10e5

File tree

4 files changed

+254
-26
lines changed

4 files changed

+254
-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

+211
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
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+
vsync: this);
34+
_animation = Tween<double>(begin: 0, end: 1).animate(_controller)
35+
..addListener(() {
36+
setState(() {});
37+
});
38+
}
39+
40+
@override
41+
void dispose() {
42+
_controller.dispose();
43+
super.dispose();
44+
}
45+
46+
late AnimationController _controller;
47+
late Animation<double> _animation;
48+
49+
double _dragExtent = 0;
50+
double get _overallDragExtent => context.size!.width / 2;
51+
52+
void _handleDragStart(DragStartDetails details) {
53+
_dragExtent = 0;
54+
}
55+
56+
void _handleDragUpdate(DragUpdateDetails details) {
57+
_dragExtent += details.delta.dx;
58+
_controller.value = _dragExtent / _overallDragExtent;
59+
}
60+
61+
void _handleDragEnd(DragEndDetails details) {
62+
_controller.reverse();
63+
}
64+
65+
@override
66+
Widget build(BuildContext context) {
67+
// on start:
68+
// remember drag start position
69+
// calculate relative position x
70+
//
71+
// on update:
72+
// the marker rect follows the pointer until it becomes a bit wider
73+
// than the maximum width (>
74+
75+
// Initial state
76+
// No text, no background, lighter colored icon
77+
// Final state
78+
// Text, light blue background, darker colored icon, text pushed to the right
79+
final theme = Theme.of(context).extension<DesignVariables>()!;
80+
81+
var marker = EditStateMarker(editState: widget.message.editState, animation: _animation);
82+
83+
final content = Stack(
84+
children: [
85+
marker,
86+
Row(
87+
crossAxisAlignment: CrossAxisAlignment.start,
88+
children: [
89+
SizedBox(width: marker.marginLeft + marker.marginRight + marker.width),
90+
Expanded(child: widget.child),
91+
SizedBox(width: 16,
92+
child: widget.message.flags.contains(MessageFlag.starred)
93+
// TODO(#157): fix how star marker aligns with message content
94+
// Design from Figma at:
95+
// https://www.figma.com/file/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=813%3A28817&mode=dev .
96+
? Padding(padding: const EdgeInsets.only(top: 5.5),
97+
child: Icon(ZulipIcons.star_filled, size: 16, color: theme.starColor))
98+
: null),
99+
]),
100+
],
101+
);
102+
103+
if (widget.message.editState == MessageEditState.none) return content;
104+
105+
return GestureDetector(
106+
onHorizontalDragStart: _handleDragStart,
107+
onHorizontalDragEnd: _handleDragEnd,
108+
onHorizontalDragUpdate: _handleDragUpdate,
109+
child: content,
110+
);
111+
}
112+
}
113+
114+
class EditStateMarker extends StatelessWidget {
115+
const EditStateMarker({
116+
super.key,
117+
required this.editState,
118+
required Animation<double> animation,
119+
}) : _animation = animation;
120+
121+
final Animation<double> _animation;
122+
final MessageEditState editState;
123+
124+
static const double _marginLeftCollapsed = 5;
125+
static const double _marignRightCollapsed = 0;
126+
static const double _widthCollapsed = 10;
127+
128+
static const double _marginLeftExpanded = 9;
129+
static const double _marginRightExpanded = 3;
130+
static const double _widthExpanded = 60;
131+
static const double _markerFontSize = 15;
132+
133+
double get marginLeft => lerpDouble(
134+
_marginLeftCollapsed,
135+
_marginLeftExpanded,
136+
_animation.value)!;
137+
double get marginRight => lerpDouble(
138+
_marignRightCollapsed,
139+
_marginRightExpanded,
140+
_animation.value)!;
141+
double get width => lerpDouble(
142+
_widthCollapsed,
143+
_widthExpanded,
144+
_animation.value)!;
145+
146+
@override
147+
Widget build(BuildContext context) {
148+
final DesignVariables theme = DesignVariables.of(context);
149+
final IconData icon;
150+
final double iconSize;
151+
final String markerText;
152+
final double iconRightMargin;
153+
final zulipLocalizations = ZulipLocalizations.of(context);
154+
155+
switch (editState) {
156+
case MessageEditState.none:
157+
return const SizedBox(width: _marginLeftCollapsed + _widthCollapsed + _marignRightCollapsed);
158+
case MessageEditState.edited:
159+
icon = ZulipIcons.edited;
160+
iconSize = 14;
161+
iconRightMargin = 0;
162+
markerText = zulipLocalizations.messageIsEdited;
163+
break;
164+
case MessageEditState.moved:
165+
icon = ZulipIcons.message_moved;
166+
iconSize = 8;
167+
iconRightMargin = 1;
168+
markerText = zulipLocalizations.messageIsMoved;
169+
break;
170+
}
171+
172+
var marker = Row(
173+
mainAxisAlignment: MainAxisAlignment.end,
174+
children: [
175+
// Apply animation for both the text and the icon
176+
Expanded(child: Text(markerText,
177+
overflow: TextOverflow.clip,
178+
softWrap: false,
179+
textAlign: TextAlign.center,
180+
style: TextStyle(fontSize: _markerFontSize, color: Color.lerp(
181+
theme.textMarker.withAlpha(0),
182+
theme.textMarker,
183+
_animation.value)))),
184+
OverflowBox(
185+
fit: OverflowBoxFit.deferToChild,
186+
maxWidth: _widthCollapsed,
187+
child: Icon(icon, size: iconSize, color: Color.lerp(
188+
theme.textMarkerLight,
189+
theme.textMarker,
190+
_animation.value)),
191+
),
192+
SizedBox(width: iconRightMargin * _animation.value),
193+
],
194+
);
195+
196+
return Padding(
197+
padding: EdgeInsets.only(top: 4, left: marginLeft),
198+
child: Container(
199+
height: _markerFontSize * 1.5,
200+
width: width,
201+
clipBehavior: Clip.hardEdge,
202+
decoration: BoxDecoration(
203+
borderRadius: BorderRadius.circular(2),
204+
color: Color.lerp(
205+
theme.bgMarker.withAlpha(0),
206+
theme.bgMarker,
207+
_animation.value)),
208+
child: marker,
209+
));
210+
}
211+
}

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)