Skip to content

Commit 4a6c46b

Browse files
committed
action_sheet: Add button to "star" and "unstar" message
Fixes: #170
1 parent 85bc0ff commit 4a6c46b

File tree

3 files changed

+162
-0
lines changed

3 files changed

+162
-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: 49 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

@@ -44,6 +45,7 @@ void showMessageActionSheet({required BuildContext context, required Message mes
4445
messageListContext: context,
4546
),
4647
CopyButton(message: message, messageListContext: context),
48+
StarButton(message: message, messageListContext: context),
4749
]);
4850
});
4951
}
@@ -317,3 +319,50 @@ 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+
String? errorMessage;
342+
final op = (message.flags.contains(MessageFlag.starred))
343+
? UpdateMessageFlagsOp.remove
344+
: UpdateMessageFlagsOp.add;
345+
try {
346+
final connection = PerAccountStoreWidget.of(messageListContext).connection;
347+
await updateMessageFlags(connection, messages: [message.id],
348+
op: op, flag: MessageFlag.starred);
349+
} catch (e) {
350+
if (!messageListContext.mounted) return;
351+
final zulipLocalizations = ZulipLocalizations.of(messageListContext);
352+
353+
switch (e) {
354+
case ZulipApiException():
355+
errorMessage = e.message;
356+
// TODO specific messages for common errors, like network errors
357+
// (support with reusable code)
358+
default:
359+
}
360+
361+
await showErrorDialog(context: messageListContext,
362+
title: switch(op) {
363+
UpdateMessageFlagsOp.add => zulipLocalizations.errorStarMessageFailedTitle,
364+
UpdateMessageFlagsOp.remove => zulipLocalizations.errorUnstarMessageFailedTitle,
365+
}, message: errorMessage);
366+
}
367+
};
368+
}

test/widgets/action_sheet_test.dart

Lines changed: 97 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,8 +9,10 @@ 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';
15+
import 'package:zulip/widgets/action_sheet.dart';
1216
import 'package:zulip/widgets/compose_box.dart';
1317
import 'package:zulip/widgets/content.dart';
1418
import 'package:zulip/widgets/message_list.dart';
@@ -386,4 +390,97 @@ void main() {
386390
check(await Clipboard.getData('text/plain')).isNull();
387391
});
388392
});
393+
394+
group('StarButton', () {
395+
Future<void> tapButton(WidgetTester tester) async {
396+
// Locating button by the icon (as other tests here do) is complicated
397+
// by the fact that starred messages are decorated with the same icon.
398+
// Selecting by button class instead here:
399+
await tester.ensureVisible(find.byType(StarButton, skipOffstage: false));
400+
await tester.tap(find.byType(StarButton));
401+
await tester.pump(); // [MenuItemButton.onPressed] called in a post-frame callback: flutter/flutter@e4a39fa2e
402+
}
403+
404+
testWidgets('star success', (WidgetTester tester) async {
405+
final message = eg.streamMessage(flags: []);
406+
await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message));
407+
final store = await testBinding.globalStore.perAccount(eg.selfAccount.id);
408+
409+
final connection = store.connection as FakeApiConnection;
410+
connection.prepare(json: {});
411+
await tapButton(tester);
412+
await tester.pump(Duration.zero);
413+
414+
check(connection.lastRequest).isA<http.Request>()
415+
..method.equals('POST')
416+
..url.path.equals('/api/v1/messages/flags')
417+
..bodyFields.deepEquals({
418+
'messages': jsonEncode([message.id]),
419+
'op': 'add',
420+
'flag': 'starred',
421+
});
422+
});
423+
424+
testWidgets('unstar success', (WidgetTester tester) async {
425+
final message = eg.streamMessage(flags: [MessageFlag.starred]);
426+
await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message));
427+
final store = await testBinding.globalStore.perAccount(eg.selfAccount.id);
428+
429+
final connection = store.connection as FakeApiConnection;
430+
connection.prepare(json: {});
431+
await tapButton(tester);
432+
await tester.pump(Duration.zero);
433+
434+
check(connection.lastRequest).isA<http.Request>()
435+
..method.equals('POST')
436+
..url.path.equals('/api/v1/messages/flags')
437+
..bodyFields.deepEquals({
438+
'messages': jsonEncode([message.id]),
439+
'op': 'remove',
440+
'flag': 'starred',
441+
});
442+
});
443+
444+
testWidgets('star request has an error', (WidgetTester tester) async {
445+
final message = eg.streamMessage(flags: []);
446+
await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message));
447+
final store = await testBinding.globalStore.perAccount(eg.selfAccount.id);
448+
final zulipLocalizations = GlobalLocalizations.zulipLocalizations;
449+
450+
final connection = store.connection as FakeApiConnection;
451+
452+
connection.prepare(httpStatus: 400, json: {
453+
'code': 'BAD_REQUEST',
454+
'msg': 'Invalid message(s)',
455+
'result': 'error',
456+
});
457+
await tapButton(tester);
458+
await tester.pump(Duration.zero); // error arrives; error dialog shows
459+
460+
await tester.tap(find.byWidget(checkErrorDialog(tester,
461+
expectedTitle: zulipLocalizations.errorStarMessageFailedTitle,
462+
expectedMessage: 'Invalid message(s)')));
463+
});
464+
465+
testWidgets('unstar request has an error', (WidgetTester tester) async {
466+
final message = eg.streamMessage(flags: [MessageFlag.starred]);
467+
await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message));
468+
final store = await testBinding.globalStore.perAccount(eg.selfAccount.id);
469+
final zulipLocalizations = GlobalLocalizations.zulipLocalizations;
470+
471+
final connection = store.connection as FakeApiConnection;
472+
473+
connection.prepare(httpStatus: 400, json: {
474+
'code': 'BAD_REQUEST',
475+
'msg': 'Invalid message(s)',
476+
'result': 'error',
477+
});
478+
await tapButton(tester);
479+
await tester.pump(Duration.zero); // error arrives; error dialog shows
480+
481+
await tester.tap(find.byWidget(checkErrorDialog(tester,
482+
expectedTitle: zulipLocalizations.errorUnstarMessageFailedTitle,
483+
expectedMessage: 'Invalid message(s)')));
484+
});
485+
});
389486
}

0 commit comments

Comments
 (0)