Skip to content

Commit 1fe0aa9

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: #79
1 parent 0e16bcb commit 1fe0aa9

File tree

3 files changed

+108
-4
lines changed

3 files changed

+108
-4
lines changed

lib/widgets/message_list.dart

Lines changed: 46 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,51 @@ 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: Container(
392+
decoration: BoxDecoration(
393+
// The color hsl(227deg 78% 59%) comes from the Figma mockup at:
394+
// https://www.figma.com/file/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=132-9684
395+
// See discussion about design at:
396+
// https://chat.zulip.org/#narrow/stream/243-mobile-team/topic/flutter.3A.20unread.20marker/near/1658008
397+
color: const HSLColor.fromAHSL(1, 227, 0.78, 0.59).toColor(),
398+
// TODO(#95): Don't show this extra border in dark mode, see:
399+
// https://github.com/zulip/zulip-flutter/pull/317#issuecomment-1784311663
400+
border: Border(left: BorderSide(
401+
width: 1,
402+
color: Colors.white.withOpacity(0.6))))))),
403+
]);
362404
}
363405
}
364406

test/flutter_checks.dart

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@ 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<AnimationStatus> get status => has((d) => d.status, 'status');
12+
Subject<T> get value => has((d) => d.value, 'value');
13+
}
14+
1015
extension ClipboardDataChecks on Subject<ClipboardData> {
1116
Subject<String?> get text => has((d) => d.text, 'text');
1217
}

test/widgets/message_list_test.dart

Lines changed: 57 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,60 @@ void main() {
300301
debugNetworkImageHttpClientProvider = null;
301302
});
302303
});
304+
305+
group('_UnreadMarker animations', () {
306+
// TODO: Improve animation state testing so it is less tied to
307+
// implementation details and more focused on output, see:
308+
// https://chat.zulip.org/#narrow/stream/243-mobile-team/topic/robust.20widget.20finders.20in.20tests/near/1671738
309+
Animation<double> getAnimation(WidgetTester tester, int messageId) {
310+
final widget = tester.widget<FadeTransition>(find.descendant(
311+
of: find.byKey(ValueKey(messageId)),
312+
matching: find.byType(FadeTransition)));
313+
return widget.opacity;
314+
}
315+
316+
testWidgets('from read to unread', (WidgetTester tester) async {
317+
final message = eg.streamMessage(flags: [MessageFlag.read]);
318+
await setupMessageListPage(tester, messages: [message]);
319+
check(getAnimation(tester, message.id))
320+
..value.equals(0.0)
321+
..status.equals(AnimationStatus.dismissed);
322+
323+
store.handleEvent(eg.updateMessageFlagsRemoveEvent(
324+
MessageFlag.read, [message]));
325+
await tester.pump(); // process handleEvent
326+
check(getAnimation(tester, message.id))
327+
..value.equals(0.0)
328+
..status.equals(AnimationStatus.forward);
329+
330+
await tester.pumpAndSettle();
331+
check(getAnimation(tester, message.id))
332+
..value.equals(1.0)
333+
..status.equals(AnimationStatus.completed);
334+
});
335+
336+
testWidgets('from unread to read', (WidgetTester tester) async {
337+
final message = eg.streamMessage(flags: []);
338+
await setupMessageListPage(tester, messages: [message]);
339+
check(getAnimation(tester, message.id))
340+
..value.equals(1.0)
341+
..status.equals(AnimationStatus.dismissed);
342+
343+
store.handleEvent(UpdateMessageFlagsAddEvent(
344+
id: 1,
345+
flag: MessageFlag.read,
346+
messages: [message.id],
347+
all: false,
348+
));
349+
await tester.pump(); // process handleEvent
350+
check(getAnimation(tester, message.id))
351+
..value.equals(1.0)
352+
..status.equals(AnimationStatus.forward);
353+
354+
await tester.pumpAndSettle();
355+
check(getAnimation(tester, message.id))
356+
..value.equals(0.0)
357+
..status.equals(AnimationStatus.completed);
358+
});
359+
});
303360
}

0 commit comments

Comments
 (0)