Skip to content

Commit b9a9774

Browse files
committed
action_sheet: Add button to "star" and "unstar" message
Fixes: #170
1 parent 2a473dd commit b9a9774

File tree

3 files changed

+166
-0
lines changed

3 files changed

+166
-0
lines changed

assets/l10n/app_en.arb

Lines changed: 16 additions & 0 deletions
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

Lines changed: 50 additions & 0 deletions
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,
@@ -317,3 +319,51 @@ class CopyButton extends MessageActionSheetMenuItemButton {
317319
data: ClipboardData(text: rawContent));
318320
};
319321
}
322+
323+
class StarButton extends MessageActionSheetMenuItemButton {
324+
StarButton({
325+
super.key,
326+
required super.message,
327+
required super.messageListContext,
328+
});
329+
330+
@override get icon => ZulipIcons.star;
331+
332+
@override
333+
String label(ZulipLocalizations zulipLocalizations) {
334+
return message.flags.contains(MessageFlag.starred)
335+
? zulipLocalizations.actionSheetOptionUnstarMessage
336+
: zulipLocalizations.actionSheetOptionStarMessage;
337+
}
338+
339+
@override get onPressed => (BuildContext context) async {
340+
Navigator.of(context).pop();
341+
final zulipLocalizations = ZulipLocalizations.of(messageListContext);
342+
final op = message.flags.contains(MessageFlag.starred)
343+
? UpdateMessageFlagsOp.remove
344+
: UpdateMessageFlagsOp.add;
345+
346+
try {
347+
final connection = PerAccountStoreWidget.of(messageListContext).connection;
348+
await updateMessageFlags(connection, messages: [message.id],
349+
op: op, flag: MessageFlag.starred);
350+
} catch (e) {
351+
if (!messageListContext.mounted) return;
352+
String? errorMessage;
353+
354+
switch (e) {
355+
case ZulipApiException():
356+
errorMessage = e.message;
357+
// TODO specific messages for common errors, like network errors
358+
// (support with reusable code)
359+
default:
360+
}
361+
362+
await showErrorDialog(context: messageListContext,
363+
title: switch(op) {
364+
UpdateMessageFlagsOp.remove => zulipLocalizations.errorUnstarMessageFailedTitle,
365+
UpdateMessageFlagsOp.add => zulipLocalizations.errorStarMessageFailedTitle,
366+
}, message: errorMessage);
367+
}
368+
};
369+
}

test/widgets/action_sheet_test.dart

Lines changed: 100 additions & 0 deletions
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';
@@ -387,4 +391,100 @@ void main() {
387391
check(await Clipboard.getData('text/plain')).isNull();
388392
});
389393
});
394+
395+
group('StarButton', () {
396+
Future<void> tapButton(WidgetTester tester) async {
397+
// Starred messages include the same icon so we need to
398+
// match only by descendants of [BottomSheet].
399+
await tester.ensureVisible(find.descendant(
400+
of: find.byType(BottomSheet),
401+
matching: find.byIcon(ZulipIcons.star, skipOffstage: false)));
402+
await tester.tap(find.descendant(
403+
of: find.byType(BottomSheet),
404+
matching: find.byIcon(ZulipIcons.star)));
405+
await tester.pump(); // [MenuItemButton.onPressed] called in a post-frame callback: flutter/flutter@e4a39fa2e
406+
}
407+
408+
testWidgets('star success', (WidgetTester tester) async {
409+
final message = eg.streamMessage(flags: []);
410+
await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message));
411+
final store = await testBinding.globalStore.perAccount(eg.selfAccount.id);
412+
413+
final connection = store.connection as FakeApiConnection;
414+
connection.prepare(json: {});
415+
await tapButton(tester);
416+
await tester.pump(Duration.zero);
417+
418+
check(connection.lastRequest).isA<http.Request>()
419+
..method.equals('POST')
420+
..url.path.equals('/api/v1/messages/flags')
421+
..bodyFields.deepEquals({
422+
'messages': jsonEncode([message.id]),
423+
'op': 'add',
424+
'flag': 'starred',
425+
});
426+
});
427+
428+
testWidgets('unstar success', (WidgetTester tester) async {
429+
final message = eg.streamMessage(flags: [MessageFlag.starred]);
430+
await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message));
431+
final store = await testBinding.globalStore.perAccount(eg.selfAccount.id);
432+
433+
final connection = store.connection as FakeApiConnection;
434+
connection.prepare(json: {});
435+
await tapButton(tester);
436+
await tester.pump(Duration.zero);
437+
438+
check(connection.lastRequest).isA<http.Request>()
439+
..method.equals('POST')
440+
..url.path.equals('/api/v1/messages/flags')
441+
..bodyFields.deepEquals({
442+
'messages': jsonEncode([message.id]),
443+
'op': 'remove',
444+
'flag': 'starred',
445+
});
446+
});
447+
448+
testWidgets('star request has an error', (WidgetTester tester) async {
449+
final message = eg.streamMessage(flags: []);
450+
await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message));
451+
final store = await testBinding.globalStore.perAccount(eg.selfAccount.id);
452+
final zulipLocalizations = GlobalLocalizations.zulipLocalizations;
453+
454+
final connection = store.connection as FakeApiConnection;
455+
456+
connection.prepare(httpStatus: 400, json: {
457+
'code': 'BAD_REQUEST',
458+
'msg': 'Invalid message(s)',
459+
'result': 'error',
460+
});
461+
await tapButton(tester);
462+
await tester.pump(Duration.zero); // error arrives; error dialog shows
463+
464+
await tester.tap(find.byWidget(checkErrorDialog(tester,
465+
expectedTitle: zulipLocalizations.errorStarMessageFailedTitle,
466+
expectedMessage: 'Invalid message(s)')));
467+
});
468+
469+
testWidgets('unstar request has an error', (WidgetTester tester) async {
470+
final message = eg.streamMessage(flags: [MessageFlag.starred]);
471+
await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message));
472+
final store = await testBinding.globalStore.perAccount(eg.selfAccount.id);
473+
final zulipLocalizations = GlobalLocalizations.zulipLocalizations;
474+
475+
final connection = store.connection as FakeApiConnection;
476+
477+
connection.prepare(httpStatus: 400, json: {
478+
'code': 'BAD_REQUEST',
479+
'msg': 'Invalid message(s)',
480+
'result': 'error',
481+
});
482+
await tapButton(tester);
483+
await tester.pump(Duration.zero); // error arrives; error dialog shows
484+
485+
await tester.tap(find.byWidget(checkErrorDialog(tester,
486+
expectedTitle: zulipLocalizations.errorUnstarMessageFailedTitle,
487+
expectedMessage: 'Invalid message(s)')));
488+
});
489+
});
390490
}

0 commit comments

Comments
 (0)