Skip to content

Commit a5fe9f8

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

File tree

2 files changed

+118
-11
lines changed

2 files changed

+118
-11
lines changed

lib/widgets/message_list.dart

+50-11
Original file line numberDiff line numberDiff line change
@@ -438,30 +438,41 @@ 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+
@visibleForTesting
452+
bool isLoading = false;
453+
446454
void _handlePress(BuildContext context) async {
447455
if (!context.mounted) return;
448456

449457
final store = PerAccountStoreWidget.of(context);
450458
final connection = store.connection;
451459
final useLegacy = connection.zulipFeatureLevel! < 155;
460+
setState(() => isLoading = true);
452461

453462
try {
454-
await markNarrowAsRead(context, narrow, useLegacy);
463+
await markNarrowAsRead(context, widget.narrow, useLegacy);
455464
} catch (e) {
456465
if (!context.mounted) return;
457466
final zulipLocalizations = ZulipLocalizations.of(context);
458467
await showErrorDialog(context: context,
459468
title: zulipLocalizations.errorMarkAsReadFailedTitle,
460469
message: e.toString()); // TODO(#741): extract user-facing message better
461470
return;
471+
} finally {
472+
setState(() => isLoading = false);
462473
}
463474
if (!context.mounted) return;
464-
if (narrow is CombinedFeedNarrow && !useLegacy) {
475+
if (widget.narrow is CombinedFeedNarrow && !useLegacy) {
465476
PerAccountStoreWidget.of(context).unreads.handleAllMessagesReadSuccess();
466477
}
467478
}
@@ -470,15 +481,14 @@ class MarkAsReadWidget extends StatelessWidget {
470481
Widget build(BuildContext context) {
471482
final zulipLocalizations = ZulipLocalizations.of(context);
472483
final store = PerAccountStoreWidget.of(context);
473-
final unreadCount = store.unreads.countInNarrow(narrow);
484+
final unreadCount = store.unreads.countInNarrow(widget.narrow);
474485
final areMessagesRead = unreadCount == 0;
475486

476487
return IgnorePointer(
477488
ignoring: areMessagesRead,
478-
child: AnimatedOpacity(
479-
opacity: areMessagesRead ? 0 : 1,
480-
duration: Duration(milliseconds: areMessagesRead ? 2000 : 300),
481-
curve: Curves.easeOut,
489+
child: MarkAsReadAnimation(
490+
visible: !areMessagesRead,
491+
loading: isLoading,
482492
child: SizedBox(width: double.infinity,
483493
// Design referenced from:
484494
// https://www.figma.com/file/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?type=design&node-id=132-9684&mode=design&t=jJwHzloKJ0TMOG4M-0
@@ -503,10 +513,9 @@ class MarkAsReadWidget extends StatelessWidget {
503513
.merge(weightVariableTextStyle(context, wght: 400))),
504514
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(7)),
505515
),
506-
onPressed: () => _handlePress(context),
516+
onPressed: () => isLoading ? null : _handlePress(context),
507517
icon: const Icon(Icons.playlist_add_check),
508-
label: Text(zulipLocalizations.markAllAsReadLabel))))),
509-
);
518+
label: Text(zulipLocalizations.markAllAsReadLabel))))));
510519
}
511520
}
512521

@@ -643,6 +652,36 @@ class _UnreadMarker extends StatelessWidget {
643652
}
644653
}
645654

655+
class MarkAsReadAnimation extends StatelessWidget {
656+
const MarkAsReadAnimation({
657+
super.key,
658+
required this.visible,
659+
required this.loading,
660+
required this.child});
661+
662+
final bool visible;
663+
final bool loading;
664+
final Widget child;
665+
666+
@override
667+
Widget build(BuildContext context) {
668+
final (opacity, scale) = loading
669+
? (0.5, 0.95)
670+
: visible ? (1.0 , 1.0) : (0.0, 0.0);
671+
const duration = Duration(milliseconds: 500);
672+
const curve = Curves.easeOut;
673+
return AnimatedScale(
674+
scale: scale,
675+
duration: duration,
676+
curve: curve,
677+
child: AnimatedOpacity(
678+
opacity: opacity,
679+
duration: duration,
680+
curve: curve,
681+
child: child));
682+
}
683+
}
684+
646685
class StreamMessageRecipientHeader extends StatelessWidget {
647686
const StreamMessageRecipientHeader({
648687
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('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(0);
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)