Skip to content

Commit 3395c57

Browse files
committed
msg_list: Use scale down with opacity animation for mark-as-read
Fixes: zulip#613
1 parent 0399af6 commit 3395c57

File tree

2 files changed

+115
-10
lines changed

2 files changed

+115
-10
lines changed

lib/widgets/message_list.dart

+47-10
Original file line numberDiff line numberDiff line change
@@ -438,30 +438,40 @@ class ScrollToBottomButton extends StatelessWidget {
438438
}
439439
}
440440

441-
class MarkAsReadWidget extends StatelessWidget {
441+
class MarkAsReadWidget extends StatefulWidget {
442442
const MarkAsReadWidget({super.key, required this.narrow});
443443

444444
final Narrow narrow;
445445

446+
@override
447+
State<MarkAsReadWidget> createState() => MarkAsReadWidgetState();
448+
}
449+
450+
class MarkAsReadWidgetState extends State<MarkAsReadWidget> {
451+
bool isLoading = false;
452+
446453
void _handlePress(BuildContext context) async {
447454
if (!context.mounted) return;
448455

449456
final store = PerAccountStoreWidget.of(context);
450457
final connection = store.connection;
451458
final useLegacy = connection.zulipFeatureLevel! < 155;
459+
setState(() => isLoading = true);
452460

453461
try {
454-
await markNarrowAsRead(context, narrow, useLegacy);
462+
await markNarrowAsRead(context, widget.narrow, useLegacy);
455463
} catch (e) {
456464
if (!context.mounted) return;
457465
final zulipLocalizations = ZulipLocalizations.of(context);
458466
await showErrorDialog(context: context,
459467
title: zulipLocalizations.errorMarkAsReadFailedTitle,
460468
message: e.toString()); // TODO(#741): extract user-facing message better
461469
return;
470+
} finally {
471+
setState(() => isLoading = false);
462472
}
463473
if (!context.mounted) return;
464-
if (narrow is CombinedFeedNarrow && !useLegacy) {
474+
if (widget.narrow is CombinedFeedNarrow && !useLegacy) {
465475
PerAccountStoreWidget.of(context).unreads.handleAllMessagesReadSuccess();
466476
}
467477
}
@@ -470,15 +480,14 @@ class MarkAsReadWidget extends StatelessWidget {
470480
Widget build(BuildContext context) {
471481
final zulipLocalizations = ZulipLocalizations.of(context);
472482
final store = PerAccountStoreWidget.of(context);
473-
final unreadCount = store.unreads.countInNarrow(narrow);
483+
final unreadCount = store.unreads.countInNarrow(widget.narrow);
474484
final areMessagesRead = unreadCount == 0;
475485

476486
return IgnorePointer(
477487
ignoring: areMessagesRead,
478-
child: AnimatedOpacity(
479-
opacity: areMessagesRead ? 0 : 1,
480-
duration: Duration(milliseconds: areMessagesRead ? 2000 : 300),
481-
curve: Curves.easeOut,
488+
child: MarkAsReadAnimation(
489+
visible: !areMessagesRead,
490+
loading: isLoading,
482491
child: SizedBox(width: double.infinity,
483492
// Design referenced from:
484493
// https://www.figma.com/file/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?type=design&node-id=132-9684&mode=design&t=jJwHzloKJ0TMOG4M-0
@@ -505,8 +514,7 @@ class MarkAsReadWidget extends StatelessWidget {
505514
),
506515
onPressed: () => _handlePress(context),
507516
icon: const Icon(Icons.playlist_add_check),
508-
label: Text(zulipLocalizations.markAllAsReadLabel))))),
509-
);
517+
label: Text(zulipLocalizations.markAllAsReadLabel))))));
510518
}
511519
}
512520

@@ -643,6 +651,35 @@ class _UnreadMarker extends StatelessWidget {
643651
}
644652
}
645653

654+
class MarkAsReadAnimation extends StatelessWidget {
655+
const MarkAsReadAnimation({
656+
super.key,
657+
required this.visible,
658+
required this.loading,
659+
required this.child});
660+
661+
final bool visible;
662+
final bool loading;
663+
final Widget child;
664+
665+
@override
666+
Widget build(BuildContext context) {
667+
final opacity = loading ? 0.5 : visible ? 1.0 : 0.0;
668+
final scale = loading ? 0.95 : 1.0;
669+
const duration = Duration(milliseconds: 300);
670+
const curve = Curves.easeOut;
671+
return AnimatedScale(
672+
scale: scale,
673+
duration: duration,
674+
curve: curve,
675+
child: AnimatedOpacity(
676+
opacity: opacity,
677+
duration: duration,
678+
curve: curve,
679+
child: child));
680+
}
681+
}
682+
646683
class StreamMessageRecipientHeader extends StatelessWidget {
647684
const StreamMessageRecipientHeader({
648685
super.key,

test/widgets/message_list_test.dart

+68
Original file line numberDiff line numberDiff line change
@@ -753,6 +753,74 @@ void main() {
753753
unreadMessageIds: [message.id]),
754754
]);
755755

756+
group('MarkAsReadAnimation', () {
757+
testWidgets('isLoading is changed correctly', (WidgetTester tester) async {
758+
final narrow = TopicNarrow.ofMessage(message);
759+
await setupMessageListPage(tester,
760+
narrow: narrow, messages: [message], unreadMsgs: unreadMsgs);
761+
check(isMarkAsReadButtonVisible(tester)).isTrue();
762+
763+
connection.prepare(json: UpdateMessageFlagsForNarrowResult(
764+
processedCount: 11, updatedCount: 3,
765+
firstProcessedId: null, lastProcessedId: null,
766+
foundOldest: true, foundNewest: true).toJson());
767+
768+
// Verify isLoading is initially false
769+
final state = tester.state<MarkAsReadWidgetState>(find.byType(MarkAsReadWidget));
770+
check(state.isLoading).isFalse();
771+
772+
// Simulate button press and verify isLoading is true
773+
await tester.tap(find.byType(MarkAsReadWidget));
774+
await tester.pump();
775+
check(state.isLoading).isTrue();
776+
777+
// Simulate completion of markNarrowAsRead and verify isLoading is false
778+
await tester.idle();
779+
await tester.pump();
780+
check(state.isLoading).isFalse();
781+
});
782+
783+
testWidgets('MarkAsReadAnimation displays correctly based on visible and loading', (WidgetTester tester) async {
784+
final child = Container();
785+
786+
// Test loading state
787+
await tester.pumpWidget(MaterialApp(
788+
home: MarkAsReadAnimation(
789+
visible: false,
790+
loading: true,
791+
child: child,
792+
),
793+
));
794+
check(find.byWidget(child).evaluate()).length.equals(1);
795+
check(tester.widget<AnimatedOpacity>(find.byType(AnimatedOpacity)).opacity).equals(0.5);
796+
check(tester.widget<AnimatedScale>(find.byType(AnimatedScale)).scale).equals(0.95);
797+
798+
// Test visible state
799+
await tester.pumpWidget(MaterialApp(
800+
home: MarkAsReadAnimation(
801+
visible: true,
802+
loading: false,
803+
child: child,
804+
),
805+
));
806+
check(find.byWidget(child).evaluate()).length.equals(1);
807+
check(tester.widget<AnimatedOpacity>(find.byType(AnimatedOpacity)).opacity).equals(1);
808+
check(tester.widget<AnimatedScale>(find.byType(AnimatedScale)).scale).equals(1);
809+
810+
// Test default state
811+
await tester.pumpWidget(MaterialApp(
812+
home: MarkAsReadAnimation(
813+
visible: false,
814+
loading: false,
815+
child: child,
816+
),
817+
));
818+
check(find.byWidget(child).evaluate()).length.equals(1);
819+
check(tester.widget<AnimatedOpacity>(find.byType(AnimatedOpacity)).opacity).equals(0);
820+
check(tester.widget<AnimatedScale>(find.byType(AnimatedScale)).scale).equals(1);
821+
});
822+
});
823+
756824
testWidgets('smoke test on modern server', (WidgetTester tester) async {
757825
final narrow = TopicNarrow.ofMessage(message);
758826
await setupMessageListPage(tester,

0 commit comments

Comments
 (0)