Skip to content

Commit c8862c3

Browse files
committed
msglist: Add animated marker for unread messages
Adds a new _UnreadMarker that renders as a 4px solid border on the left edge of messages. Fixes: zulip#79
1 parent 49f5892 commit c8862c3

File tree

3 files changed

+103
-4
lines changed

3 files changed

+103
-4
lines changed

lib/widgets/message_list.dart

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,7 @@ class _MessageListState extends State<MessageList> with PerAccountStoreAwareStat
283283
header: header, child: header);
284284
case MessageListMessageItem():
285285
return MessageItem(
286+
key: ValueKey(data.message.id),
286287
trailing: i == 0 ? const SizedBox(height: 8) : const SizedBox(height: 11),
287288
item: data);
288289
}
@@ -355,10 +356,45 @@ class MessageItem extends StatelessWidget {
355356
return StickyHeaderItem(
356357
allowOverflow: !item.isLastInBlock,
357358
header: RecipientHeader(message: message),
358-
child: Column(children: [
359-
MessageWithPossibleSender(item: item),
360-
if (trailing != null && item.isLastInBlock) trailing!,
361-
]));
359+
child: _UnreadMarker(
360+
isRead: message.flags.contains(MessageFlag.read),
361+
child: Column(children: [
362+
MessageWithPossibleSender(item: item),
363+
if (trailing != null && item.isLastInBlock) trailing!,
364+
])));
365+
}
366+
}
367+
368+
/// Widget responsible for showing the read status of a message.
369+
class _UnreadMarker extends StatelessWidget {
370+
const _UnreadMarker({required this.isRead, required this.child});
371+
372+
final bool isRead;
373+
final Widget child;
374+
375+
@override
376+
Widget build(BuildContext context) {
377+
return Stack(
378+
children: [
379+
child,
380+
Positioned(
381+
top: 0,
382+
left: 0,
383+
bottom: 0,
384+
width: 4,
385+
child: AnimatedOpacity(
386+
opacity: isRead ? 0 : 1,
387+
// Web uses 2s and 0.3s durations, and a CSS ease-out curve.
388+
// See zulip:web/styles/message_row.css .
389+
duration: Duration(milliseconds: isRead ? 2000 : 300),
390+
curve: Curves.easeOut,
391+
child: ColoredBox(
392+
// The color hsl(227deg 78% 59%) comes from the Figma mockup at:
393+
// https://www.figma.com/file/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=132-9684
394+
// See discussion about design at:
395+
// https://chat.zulip.org/#narrow/stream/243-mobile-team/topic/flutter.3A.20unread.20marker/near/1658008
396+
color: const HSLColor.fromAHSL(1, 227, 0.78, 0.59).toColor()))),
397+
]);
362398
}
363399
}
364400

test/flutter_checks.dart

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,13 @@ import 'package:checks/checks.dart';
77
import 'package:flutter/services.dart';
88
import 'package:flutter/widgets.dart';
99

10+
extension AnimationChecks<T> on Subject<Animation<T>> {
11+
Subject<bool> get isCompleted => has((d) => d.isCompleted, 'isCompleted');
12+
Subject<bool> get isDismissed => has((d) => d.isDismissed, 'isDismissed');
13+
Subject<AnimationStatus> get status => has((d) => d.status, 'status');
14+
Subject<T> get value => has((d) => d.value, 'value');
15+
}
16+
1017
extension ClipboardDataChecks on Subject<ClipboardData> {
1118
Subject<String?> get text => has((d) => d.text, 'text');
1219
}

test/widgets/message_list_test.dart

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import '../example_data.dart' as eg;
1717
import '../model/binding.dart';
1818
import '../model/message_list_test.dart';
1919
import '../model/test_store.dart';
20+
import '../flutter_checks.dart';
2021
import '../stdlib_checks.dart';
2122
import '../test_images.dart';
2223
import 'content_checks.dart';
@@ -300,4 +301,59 @@ void main() {
300301
debugNetworkImageHttpClientProvider = null;
301302
});
302303
});
304+
305+
group('_UnreadMarker animations', () {
306+
Animation<double> getAnimation(WidgetTester tester, int messageId) {
307+
final widget = tester.widget<FadeTransition>(find.descendant(
308+
of: find.byKey(ValueKey(messageId)),
309+
matching: find.byType(FadeTransition)));
310+
return widget.opacity;
311+
}
312+
313+
testWidgets('from read to unread', (WidgetTester tester) async {
314+
final message = eg.streamMessage(flags: [MessageFlag.read]);
315+
await setupMessageListPage(tester, messages: [message]);
316+
check(getAnimation(tester, message.id))
317+
..value.equals(0.0)
318+
..status.equals(AnimationStatus.dismissed);
319+
320+
store.handleEvent(eg.updateMessageFlagsRemoveEvent(
321+
MessageFlag.read, [message]));
322+
await tester.pump(); // process handleEvent
323+
check(getAnimation(tester, message.id))
324+
..value.equals(0.0)
325+
..status.equals(AnimationStatus.forward);
326+
327+
await tester.pumpAndSettle();
328+
329+
check(getAnimation(tester, message.id))
330+
..value.equals(1.0)
331+
..status.equals(AnimationStatus.completed);
332+
});
333+
334+
testWidgets('from unread to read', (WidgetTester tester) async {
335+
final message = eg.streamMessage(flags: []);
336+
await setupMessageListPage(tester, messages: [message]);
337+
check(getAnimation(tester, message.id))
338+
..value.equals(1.0)
339+
..status.equals(AnimationStatus.dismissed);
340+
341+
store.handleEvent(UpdateMessageFlagsAddEvent(
342+
id: 1,
343+
flag: MessageFlag.read,
344+
messages: [message.id],
345+
all: false,
346+
));
347+
await tester.pump(); // process handleEvent
348+
check(getAnimation(tester, message.id))
349+
..value.equals(1.0)
350+
..status.equals(AnimationStatus.forward);
351+
352+
await tester.pumpAndSettle();
353+
354+
check(getAnimation(tester, message.id))
355+
..value.equals(0.0)
356+
..status.equals(AnimationStatus.completed);
357+
});
358+
});
303359
}

0 commit comments

Comments
 (0)