diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index 5cebbbe019..15b78ffbfd 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -458,30 +458,40 @@ class ScrollToBottomButton extends StatelessWidget { } } -class MarkAsReadWidget extends StatelessWidget { +class MarkAsReadWidget extends StatefulWidget { const MarkAsReadWidget({super.key, required this.narrow}); final Narrow narrow; + @override + State createState() => _MarkAsReadWidgetState(); +} + +class _MarkAsReadWidgetState extends State { + bool _loading = false; + void _handlePress(BuildContext context) async { if (!context.mounted) return; final store = PerAccountStoreWidget.of(context); final connection = store.connection; final useLegacy = connection.zulipFeatureLevel! < 155; + setState(() => _loading = true); try { - await markNarrowAsRead(context, narrow, useLegacy); + await markNarrowAsRead(context, widget.narrow, useLegacy); } catch (e) { if (!context.mounted) return; final zulipLocalizations = ZulipLocalizations.of(context); - await showErrorDialog(context: context, + showErrorDialog(context: context, title: zulipLocalizations.errorMarkAsReadFailedTitle, message: e.toString()); // TODO(#741): extract user-facing message better return; + } finally { + setState(() => _loading = false); } if (!context.mounted) return; - if (narrow is CombinedFeedNarrow && !useLegacy) { + if (widget.narrow is CombinedFeedNarrow && !useLegacy) { PerAccountStoreWidget.of(context).unreads.handleAllMessagesReadSuccess(); } } @@ -490,15 +500,14 @@ class MarkAsReadWidget extends StatelessWidget { Widget build(BuildContext context) { final zulipLocalizations = ZulipLocalizations.of(context); final store = PerAccountStoreWidget.of(context); - final unreadCount = store.unreads.countInNarrow(narrow); + final unreadCount = store.unreads.countInNarrow(widget.narrow); final areMessagesRead = unreadCount == 0; return IgnorePointer( ignoring: areMessagesRead, - child: AnimatedOpacity( - opacity: areMessagesRead ? 0 : 1, - duration: Duration(milliseconds: areMessagesRead ? 2000 : 300), - curve: Curves.easeOut, + child: MarkAsReadAnimation( + loading: _loading, + hidden: areMessagesRead, child: SizedBox(width: double.infinity, // Design referenced from: // https://www.figma.com/file/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?type=design&node-id=132-9684&mode=design&t=jJwHzloKJ0TMOG4M-0 @@ -507,8 +516,7 @@ class MarkAsReadWidget extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10 - ((48 - 38) / 2)), child: FilledButton.icon( style: FilledButton.styleFrom( - // TODO(#95) need dark-theme colors (foreground and background) - backgroundColor: _UnreadMarker.color, + splashFactory: NoSplash.splashFactory, minimumSize: const Size.fromHeight(38), textStyle: // Restate [FilledButton]'s default, which inherits from @@ -522,11 +530,60 @@ class MarkAsReadWidget extends StatelessWidget { height: (23 / 18)) .merge(weightVariableTextStyle(context, wght: 400))), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(7)), + ).copyWith( + // Give the buttons a constant color regardless of whether their + // state is disabled, pressed, etc. We handle those states + // separately, via MarkAsReadAnimation. + // TODO(#95) need dark-theme colors (foreground and background) + foregroundColor: WidgetStateColor.resolveWith((_) => Colors.white), + backgroundColor: WidgetStateColor.resolveWith((_) => _UnreadMarker.color), ), - onPressed: () => _handlePress(context), + onPressed: _loading ? null : () => _handlePress(context), icon: const Icon(Icons.playlist_add_check), - label: Text(zulipLocalizations.markAllAsReadLabel))))), - ); + label: Text(zulipLocalizations.markAllAsReadLabel)))))); + } +} + +class MarkAsReadAnimation extends StatefulWidget { + final bool loading; + final bool hidden; + final Widget child; + + const MarkAsReadAnimation({ + super.key, + required this.loading, + required this.hidden, + required this.child + }); + + @override + State createState() => _MarkAsReadAnimationState(); +} + +class _MarkAsReadAnimationState extends State { + bool _isPressed = false; + + void _setIsPressed(bool isPressed) { + setState(() { + _isPressed = isPressed; + }); + } + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTapDown: (_) => _setIsPressed(true), + onTapUp: (_) => _setIsPressed(false), + onTapCancel: () => _setIsPressed(false), + child: AnimatedScale( + scale: _isPressed ? 0.95 : 1, + duration: const Duration(milliseconds: 100), + curve: Curves.easeOut, + child: AnimatedOpacity( + opacity: widget.hidden ? 0 : widget.loading ? 0.5 : 1, + duration: const Duration(milliseconds: 500), + curve: Curves.easeOut, + child: widget.child))); } } diff --git a/test/widgets/message_list_test.dart b/test/widgets/message_list_test.dart index d5744f2051..749e417566 100644 --- a/test/widgets/message_list_test.dart +++ b/test/widgets/message_list_test.dart @@ -883,6 +883,65 @@ void main() { unreadMessageIds: [message.id]), ]); + group('MarkAsReadAnimation', () { + void checkAppearsLoading(WidgetTester tester, bool expected) { + final semantics = tester.firstWidget(find.descendant( + of: find.byType(MarkAsReadWidget), + matching: find.byType(Semantics))); + check(semantics.properties.enabled).equals(!expected); + + final opacity = tester.widget(find.descendant( + of: find.byType(MarkAsReadWidget), + matching: find.byType(AnimatedOpacity))); + check(opacity.opacity).equals(expected ? 0.5 : 1.0); + } + + testWidgets('loading is changed correctly', (WidgetTester tester) async { + final narrow = TopicNarrow.ofMessage(message); + await setupMessageListPage(tester, + narrow: narrow, messages: [message], unreadMsgs: unreadMsgs); + check(isMarkAsReadButtonVisible(tester)).isTrue(); + + connection.prepare( + delay: const Duration(milliseconds: 2000), + json: UpdateMessageFlagsForNarrowResult( + processedCount: 11, updatedCount: 3, + firstProcessedId: null, lastProcessedId: null, + foundOldest: true, foundNewest: true).toJson()); + + checkAppearsLoading(tester, false); + + await tester.tap(find.byType(MarkAsReadWidget)); + await tester.pump(); + checkAppearsLoading(tester, true); + + await tester.pump(const Duration(milliseconds: 2000)); + checkAppearsLoading(tester, false); + }); + + testWidgets('loading is changed correctly if request fails', (WidgetTester tester) async { + final narrow = TopicNarrow.ofMessage(message); + await setupMessageListPage(tester, + narrow: narrow, messages: [message], unreadMsgs: unreadMsgs); + check(isMarkAsReadButtonVisible(tester)).isTrue(); + + connection.prepare(httpStatus: 400, json: { + 'code': 'BAD_REQUEST', + 'msg': 'Invalid message(s)', + 'result': 'error', + }); + + checkAppearsLoading(tester, false); + + await tester.tap(find.byType(MarkAsReadWidget)); + await tester.pump(); + checkAppearsLoading(tester, true); + + await tester.pump(const Duration(milliseconds: 2000)); + checkAppearsLoading(tester, false); + }); + }); + testWidgets('smoke test on modern server', (WidgetTester tester) async { final narrow = TopicNarrow.ofMessage(message); await setupMessageListPage(tester,