From ffb814d22fbb23ef27ba0fbad05cec0286ad9f1a Mon Sep 17 00:00:00 2001 From: Shu Chen Date: Mon, 4 Dec 2023 19:33:41 +0000 Subject: [PATCH] action_sheet: Add button to "star" and "unstar" message Fixes: #170 --- assets/l10n/app_en.arb | 16 +++++ lib/widgets/action_sheet.dart | 50 ++++++++++++++ test/widgets/action_sheet_test.dart | 100 ++++++++++++++++++++++++++++ 3 files changed, 166 insertions(+) diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index 574aded4f9b..63e8249b72f 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -55,6 +55,14 @@ "@actionSheetOptionQuoteAndReply": { "description": "Label for Quote and reply button on action sheet." }, + "actionSheetOptionStarMessage": "Star message", + "@actionSheetOptionStarMessage": { + "description": "Label for star button on action sheet." + }, + "actionSheetOptionUnstarMessage": "Unstar message", + "@actionSheetOptionUnstarMessage": { + "description": "Label for unstar button on action sheet." + }, "errorCouldNotFetchMessageSource": "Could not fetch message source", "@errorCouldNotFetchMessageSource": { "description": "Error message when the source of a message could not be fetched." @@ -128,6 +136,14 @@ "@errorSharingFailed": { "description": "Error message when sharing a message failed." }, + "errorStarMessageFailedTitle": "Failed to star message", + "@errorStarMessageFailedTitle": { + "description": "Error title when starring a message failed." + }, + "errorUnstarMessageFailedTitle": "Failed to unstar message", + "@errorUnstarMessageFailedTitle": { + "description": "Error title when unstarring a message failed." + }, "successLinkCopied": "Link copied", "@successLinkCopied": { "description": "Success message after copy link action completed." diff --git a/lib/widgets/action_sheet.dart b/lib/widgets/action_sheet.dart index a35cb08f3bc..1159d69c8f5 100644 --- a/lib/widgets/action_sheet.dart +++ b/lib/widgets/action_sheet.dart @@ -10,6 +10,7 @@ import 'clipboard.dart'; import 'compose_box.dart'; import 'dialog.dart'; import 'draggable_scrollable_modal_bottom_sheet.dart'; +import 'icons.dart'; import 'message_list.dart'; import 'store.dart'; @@ -38,6 +39,7 @@ void showMessageActionSheet({required BuildContext context, required Message mes builder: (BuildContext _) { return Column(children: [ if (!hasThumbsUpReactionVote) AddThumbsUpButton(message: message, messageListContext: context), + StarButton(message: message, messageListContext: context), ShareButton(message: message, messageListContext: context), if (isComposeBoxOffered) QuoteAndReplyButton( message: message, @@ -115,6 +117,54 @@ class AddThumbsUpButton extends MessageActionSheetMenuItemButton { }; } +class StarButton extends MessageActionSheetMenuItemButton { + StarButton({ + super.key, + required super.message, + required super.messageListContext, + }); + + @override get icon => ZulipIcons.star; + + @override + String label(ZulipLocalizations zulipLocalizations) { + return message.flags.contains(MessageFlag.starred) + ? zulipLocalizations.actionSheetOptionUnstarMessage + : zulipLocalizations.actionSheetOptionStarMessage; + } + + @override get onPressed => (BuildContext context) async { + Navigator.of(context).pop(); + final zulipLocalizations = ZulipLocalizations.of(messageListContext); + final op = message.flags.contains(MessageFlag.starred) + ? UpdateMessageFlagsOp.remove + : UpdateMessageFlagsOp.add; + + try { + final connection = PerAccountStoreWidget.of(messageListContext).connection; + await updateMessageFlags(connection, messages: [message.id], + op: op, flag: MessageFlag.starred); + } catch (e) { + if (!messageListContext.mounted) return; + + String? errorMessage; + switch (e) { + case ZulipApiException(): + errorMessage = e.message; + // TODO specific messages for common errors, like network errors + // (support with reusable code) + default: + } + + await showErrorDialog(context: messageListContext, + title: switch(op) { + UpdateMessageFlagsOp.remove => zulipLocalizations.errorUnstarMessageFailedTitle, + UpdateMessageFlagsOp.add => zulipLocalizations.errorStarMessageFailedTitle, + }, message: errorMessage); + } + }; +} + class ShareButton extends MessageActionSheetMenuItemButton { ShareButton({ super.key, diff --git a/test/widgets/action_sheet_test.dart b/test/widgets/action_sheet_test.dart index e4b1df53e9f..3a5a7361db5 100644 --- a/test/widgets/action_sheet_test.dart +++ b/test/widgets/action_sheet_test.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + import 'package:checks/checks.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -7,10 +9,12 @@ import 'package:http/http.dart' as http; import 'package:zulip/api/model/model.dart'; import 'package:zulip/api/route/messages.dart'; import 'package:zulip/model/compose.dart'; +import 'package:zulip/model/localizations.dart'; import 'package:zulip/model/narrow.dart'; import 'package:zulip/model/store.dart'; import 'package:zulip/widgets/compose_box.dart'; import 'package:zulip/widgets/content.dart'; +import 'package:zulip/widgets/icons.dart'; import 'package:zulip/widgets/message_list.dart'; import 'package:zulip/widgets/store.dart'; import 'package:share_plus_platform_interface/method_channel/method_channel_share.dart'; @@ -142,6 +146,102 @@ void main() { }); }); + group('StarButton', () { + Future tapButton(WidgetTester tester) async { + // Starred messages include the same icon so we need to + // match only by descendants of [BottomSheet]. + await tester.ensureVisible(find.descendant( + of: find.byType(BottomSheet), + matching: find.byIcon(ZulipIcons.star, skipOffstage: false))); + await tester.tap(find.descendant( + of: find.byType(BottomSheet), + matching: find.byIcon(ZulipIcons.star))); + await tester.pump(); // [MenuItemButton.onPressed] called in a post-frame callback: flutter/flutter@e4a39fa2e + } + + testWidgets('star success', (WidgetTester tester) async { + final message = eg.streamMessage(flags: []); + await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); + final store = await testBinding.globalStore.perAccount(eg.selfAccount.id); + + final connection = store.connection as FakeApiConnection; + connection.prepare(json: {}); + await tapButton(tester); + await tester.pump(Duration.zero); + + check(connection.lastRequest).isA() + ..method.equals('POST') + ..url.path.equals('/api/v1/messages/flags') + ..bodyFields.deepEquals({ + 'messages': jsonEncode([message.id]), + 'op': 'add', + 'flag': 'starred', + }); + }); + + testWidgets('unstar success', (WidgetTester tester) async { + final message = eg.streamMessage(flags: [MessageFlag.starred]); + await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); + final store = await testBinding.globalStore.perAccount(eg.selfAccount.id); + + final connection = store.connection as FakeApiConnection; + connection.prepare(json: {}); + await tapButton(tester); + await tester.pump(Duration.zero); + + check(connection.lastRequest).isA() + ..method.equals('POST') + ..url.path.equals('/api/v1/messages/flags') + ..bodyFields.deepEquals({ + 'messages': jsonEncode([message.id]), + 'op': 'remove', + 'flag': 'starred', + }); + }); + + testWidgets('star request has an error', (WidgetTester tester) async { + final message = eg.streamMessage(flags: []); + await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); + final store = await testBinding.globalStore.perAccount(eg.selfAccount.id); + final zulipLocalizations = GlobalLocalizations.zulipLocalizations; + + final connection = store.connection as FakeApiConnection; + + connection.prepare(httpStatus: 400, json: { + 'code': 'BAD_REQUEST', + 'msg': 'Invalid message(s)', + 'result': 'error', + }); + await tapButton(tester); + await tester.pump(Duration.zero); // error arrives; error dialog shows + + await tester.tap(find.byWidget(checkErrorDialog(tester, + expectedTitle: zulipLocalizations.errorStarMessageFailedTitle, + expectedMessage: 'Invalid message(s)'))); + }); + + testWidgets('unstar request has an error', (WidgetTester tester) async { + final message = eg.streamMessage(flags: [MessageFlag.starred]); + await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); + final store = await testBinding.globalStore.perAccount(eg.selfAccount.id); + final zulipLocalizations = GlobalLocalizations.zulipLocalizations; + + final connection = store.connection as FakeApiConnection; + + connection.prepare(httpStatus: 400, json: { + 'code': 'BAD_REQUEST', + 'msg': 'Invalid message(s)', + 'result': 'error', + }); + await tapButton(tester); + await tester.pump(Duration.zero); // error arrives; error dialog shows + + await tester.tap(find.byWidget(checkErrorDialog(tester, + expectedTitle: zulipLocalizations.errorUnstarMessageFailedTitle, + expectedMessage: 'Invalid message(s)'))); + }); + }); + group('ShareButton', () { // Tests should call this. MockSharePlus setupMockSharePlus() {