Skip to content

Commit 959c2ad

Browse files
committed
action_sheet: Add button to "star" and "unstar" message
Fixes: #170
1 parent 81c0741 commit 959c2ad

File tree

3 files changed

+166
-0
lines changed

3 files changed

+166
-0
lines changed

assets/l10n/app_en.arb

+16
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,14 @@
5555
"@actionSheetOptionQuoteAndReply": {
5656
"description": "Label for Quote and reply button on action sheet."
5757
},
58+
"actionSheetOptionStarMessage": "Star message",
59+
"@actionSheetOptionStarMessage": {
60+
"description": "Label for star button on action sheet."
61+
},
62+
"actionSheetOptionUnstarMessage": "Unstar message",
63+
"@actionSheetOptionUnstarMessage": {
64+
"description": "Label for unstar button on action sheet."
65+
},
5866
"errorCouldNotFetchMessageSource": "Could not fetch message source",
5967
"@errorCouldNotFetchMessageSource": {
6068
"description": "Error message when the source of a message could not be fetched."
@@ -128,6 +136,14 @@
128136
"@errorSharingFailed": {
129137
"description": "Error message when sharing a message failed."
130138
},
139+
"errorStarMessageFailedTitle": "Failed to star message",
140+
"@errorStarMessageFailedTitle": {
141+
"description": "Error title when starring a message failed."
142+
},
143+
"errorUnstarMessageFailedTitle": "Failed to unstar message",
144+
"@errorUnstarMessageFailedTitle": {
145+
"description": "Error title when unstarring a message failed."
146+
},
131147
"successLinkCopied": "Link copied",
132148
"@successLinkCopied": {
133149
"description": "Success message after copy link action completed."

lib/widgets/action_sheet.dart

+50
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import 'clipboard.dart';
1010
import 'compose_box.dart';
1111
import 'dialog.dart';
1212
import 'draggable_scrollable_modal_bottom_sheet.dart';
13+
import 'icons.dart';
1314
import 'message_list.dart';
1415
import 'store.dart';
1516

@@ -38,6 +39,7 @@ void showMessageActionSheet({required BuildContext context, required Message mes
3839
builder: (BuildContext _) {
3940
return Column(children: [
4041
if (!hasThumbsUpReactionVote) AddThumbsUpButton(message: message, messageListContext: context),
42+
StarButton(message: message, messageListContext: context),
4143
ShareButton(message: message, messageListContext: context),
4244
if (isComposeBoxOffered) QuoteAndReplyButton(
4345
message: message,
@@ -115,6 +117,54 @@ class AddThumbsUpButton extends MessageActionSheetMenuItemButton {
115117
};
116118
}
117119

120+
class StarButton extends MessageActionSheetMenuItemButton {
121+
StarButton({
122+
super.key,
123+
required super.message,
124+
required super.messageListContext,
125+
});
126+
127+
@override get icon => ZulipIcons.star_filled;
128+
129+
@override
130+
String label(ZulipLocalizations zulipLocalizations) {
131+
return message.flags.contains(MessageFlag.starred)
132+
? zulipLocalizations.actionSheetOptionUnstarMessage
133+
: zulipLocalizations.actionSheetOptionStarMessage;
134+
}
135+
136+
@override get onPressed => (BuildContext context) async {
137+
Navigator.of(context).pop();
138+
final zulipLocalizations = ZulipLocalizations.of(messageListContext);
139+
final op = message.flags.contains(MessageFlag.starred)
140+
? UpdateMessageFlagsOp.remove
141+
: UpdateMessageFlagsOp.add;
142+
143+
try {
144+
final connection = PerAccountStoreWidget.of(messageListContext).connection;
145+
await updateMessageFlags(connection, messages: [message.id],
146+
op: op, flag: MessageFlag.starred);
147+
} catch (e) {
148+
if (!messageListContext.mounted) return;
149+
150+
String? errorMessage;
151+
switch (e) {
152+
case ZulipApiException():
153+
errorMessage = e.message;
154+
// TODO specific messages for common errors, like network errors
155+
// (support with reusable code)
156+
default:
157+
}
158+
159+
await showErrorDialog(context: messageListContext,
160+
title: switch(op) {
161+
UpdateMessageFlagsOp.remove => zulipLocalizations.errorUnstarMessageFailedTitle,
162+
UpdateMessageFlagsOp.add => zulipLocalizations.errorStarMessageFailedTitle,
163+
}, message: errorMessage);
164+
}
165+
};
166+
}
167+
118168
class ShareButton extends MessageActionSheetMenuItemButton {
119169
ShareButton({
120170
super.key,

test/widgets/action_sheet_test.dart

+100
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import 'dart:convert';
2+
13
import 'package:checks/checks.dart';
24
import 'package:flutter/material.dart';
35
import 'package:flutter/services.dart';
@@ -7,10 +9,12 @@ import 'package:http/http.dart' as http;
79
import 'package:zulip/api/model/model.dart';
810
import 'package:zulip/api/route/messages.dart';
911
import 'package:zulip/model/compose.dart';
12+
import 'package:zulip/model/localizations.dart';
1013
import 'package:zulip/model/narrow.dart';
1114
import 'package:zulip/model/store.dart';
1215
import 'package:zulip/widgets/compose_box.dart';
1316
import 'package:zulip/widgets/content.dart';
17+
import 'package:zulip/widgets/icons.dart';
1418
import 'package:zulip/widgets/message_list.dart';
1519
import 'package:zulip/widgets/store.dart';
1620
import 'package:share_plus_platform_interface/method_channel/method_channel_share.dart';
@@ -142,6 +146,102 @@ void main() {
142146
});
143147
});
144148

149+
group('StarButton', () {
150+
Future<void> tapButton(WidgetTester tester) async {
151+
// Starred messages include the same icon so we need to
152+
// match only by descendants of [BottomSheet].
153+
await tester.ensureVisible(find.descendant(
154+
of: find.byType(BottomSheet),
155+
matching: find.byIcon(ZulipIcons.star_filled, skipOffstage: false)));
156+
await tester.tap(find.descendant(
157+
of: find.byType(BottomSheet),
158+
matching: find.byIcon(ZulipIcons.star_filled)));
159+
await tester.pump(); // [MenuItemButton.onPressed] called in a post-frame callback: flutter/flutter@e4a39fa2e
160+
}
161+
162+
testWidgets('star success', (WidgetTester tester) async {
163+
final message = eg.streamMessage(flags: []);
164+
await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message));
165+
final store = await testBinding.globalStore.perAccount(eg.selfAccount.id);
166+
167+
final connection = store.connection as FakeApiConnection;
168+
connection.prepare(json: {});
169+
await tapButton(tester);
170+
await tester.pump(Duration.zero);
171+
172+
check(connection.lastRequest).isA<http.Request>()
173+
..method.equals('POST')
174+
..url.path.equals('/api/v1/messages/flags')
175+
..bodyFields.deepEquals({
176+
'messages': jsonEncode([message.id]),
177+
'op': 'add',
178+
'flag': 'starred',
179+
});
180+
});
181+
182+
testWidgets('unstar success', (WidgetTester tester) async {
183+
final message = eg.streamMessage(flags: [MessageFlag.starred]);
184+
await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message));
185+
final store = await testBinding.globalStore.perAccount(eg.selfAccount.id);
186+
187+
final connection = store.connection as FakeApiConnection;
188+
connection.prepare(json: {});
189+
await tapButton(tester);
190+
await tester.pump(Duration.zero);
191+
192+
check(connection.lastRequest).isA<http.Request>()
193+
..method.equals('POST')
194+
..url.path.equals('/api/v1/messages/flags')
195+
..bodyFields.deepEquals({
196+
'messages': jsonEncode([message.id]),
197+
'op': 'remove',
198+
'flag': 'starred',
199+
});
200+
});
201+
202+
testWidgets('star request has an error', (WidgetTester tester) async {
203+
final message = eg.streamMessage(flags: []);
204+
await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message));
205+
final store = await testBinding.globalStore.perAccount(eg.selfAccount.id);
206+
final zulipLocalizations = GlobalLocalizations.zulipLocalizations;
207+
208+
final connection = store.connection as FakeApiConnection;
209+
210+
connection.prepare(httpStatus: 400, json: {
211+
'code': 'BAD_REQUEST',
212+
'msg': 'Invalid message(s)',
213+
'result': 'error',
214+
});
215+
await tapButton(tester);
216+
await tester.pump(Duration.zero); // error arrives; error dialog shows
217+
218+
await tester.tap(find.byWidget(checkErrorDialog(tester,
219+
expectedTitle: zulipLocalizations.errorStarMessageFailedTitle,
220+
expectedMessage: 'Invalid message(s)')));
221+
});
222+
223+
testWidgets('unstar request has an error', (WidgetTester tester) async {
224+
final message = eg.streamMessage(flags: [MessageFlag.starred]);
225+
await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message));
226+
final store = await testBinding.globalStore.perAccount(eg.selfAccount.id);
227+
final zulipLocalizations = GlobalLocalizations.zulipLocalizations;
228+
229+
final connection = store.connection as FakeApiConnection;
230+
231+
connection.prepare(httpStatus: 400, json: {
232+
'code': 'BAD_REQUEST',
233+
'msg': 'Invalid message(s)',
234+
'result': 'error',
235+
});
236+
await tapButton(tester);
237+
await tester.pump(Duration.zero); // error arrives; error dialog shows
238+
239+
await tester.tap(find.byWidget(checkErrorDialog(tester,
240+
expectedTitle: zulipLocalizations.errorUnstarMessageFailedTitle,
241+
expectedMessage: 'Invalid message(s)')));
242+
});
243+
});
244+
145245
group('ShareButton', () {
146246
// Tests should call this.
147247
MockSharePlus setupMockSharePlus() {

0 commit comments

Comments
 (0)