diff --git a/assets/icons/ZulipIcons.ttf b/assets/icons/ZulipIcons.ttf
index 66d69be4b1..4fa457ba54 100644
Binary files a/assets/icons/ZulipIcons.ttf and b/assets/icons/ZulipIcons.ttf differ
diff --git a/assets/icons/trash.svg b/assets/icons/trash.svg
new file mode 100644
index 0000000000..8642c16d5a
--- /dev/null
+++ b/assets/icons/trash.svg
@@ -0,0 +1,5 @@
+
diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb
index bd0d7efdd5..73bcb373b0 100644
--- a/assets/l10n/app_en.arb
+++ b/assets/l10n/app_en.arb
@@ -277,6 +277,26 @@
"@actionSheetOptionEditMessage": {
"description": "Label for the 'Edit message' button in the message action sheet."
},
+ "actionSheetOptionDeleteMessage": "Delete message",
+ "@actionSheetOptionDeleteMessage": {
+ "description": "Label for the 'Delete message' button in the message action sheet."
+ },
+ "deleteMessageConfirmationDialogTitle": "Delete message?",
+ "@deleteMessageConfirmationDialogTitle": {
+ "description": "Title for a confirmation dialog for deleting a message."
+ },
+ "deleteMessageConfirmationDialogMessage": "Deleting a message permanently removes it for everyone.",
+ "@deleteMessageConfirmationDialogMessage": {
+ "description": "Message for a confirmation dialog for deleting a message."
+ },
+ "deleteMessageConfirmationDialogConfirmButton": "Delete",
+ "@deleteMessageConfirmationDialogConfirmButton": {
+ "description": "Label for the 'Delete' button on a confirmation dialog for deleting a message."
+ },
+ "errorDeleteMessageFailedTitle": "Failed to delete message",
+ "@errorDeleteMessageFailedTitle": {
+ "description": "Error title when deleting a message failed."
+ },
"actionSheetOptionMarkTopicAsRead": "Mark topic as read",
"@actionSheetOptionMarkTopicAsRead": {
"description": "Option to mark a specific topic as read in the action sheet."
diff --git a/lib/api/route/messages.dart b/lib/api/route/messages.dart
index 7460a02461..6d0487f0d7 100644
--- a/lib/api/route/messages.dart
+++ b/lib/api/route/messages.dart
@@ -250,6 +250,14 @@ class UpdateMessageResult {
Map toJson() => _$UpdateMessageResultToJson(this);
}
+/// https://zulip.com/api/delete-message
+Future deleteMessage(
+ ApiConnection connection, {
+ required int messageId,
+}) {
+ return connection.delete('deleteMessage', (_) {}, 'messages/$messageId', {});
+}
+
/// https://zulip.com/api/upload-file
Future uploadFile(
ApiConnection connection, {
diff --git a/lib/generated/l10n/zulip_localizations.dart b/lib/generated/l10n/zulip_localizations.dart
index 276ae504f7..44fadaa2ec 100644
--- a/lib/generated/l10n/zulip_localizations.dart
+++ b/lib/generated/l10n/zulip_localizations.dart
@@ -521,6 +521,36 @@ abstract class ZulipLocalizations {
/// **'Edit message'**
String get actionSheetOptionEditMessage;
+ /// Label for the 'Delete message' button in the message action sheet.
+ ///
+ /// In en, this message translates to:
+ /// **'Delete message'**
+ String get actionSheetOptionDeleteMessage;
+
+ /// Title for a confirmation dialog for deleting a message.
+ ///
+ /// In en, this message translates to:
+ /// **'Delete message?'**
+ String get deleteMessageConfirmationDialogTitle;
+
+ /// Message for a confirmation dialog for deleting a message.
+ ///
+ /// In en, this message translates to:
+ /// **'Deleting a message permanently removes it for everyone.'**
+ String get deleteMessageConfirmationDialogMessage;
+
+ /// Label for the 'Delete' button on a confirmation dialog for deleting a message.
+ ///
+ /// In en, this message translates to:
+ /// **'Delete'**
+ String get deleteMessageConfirmationDialogConfirmButton;
+
+ /// Error title when deleting a message failed.
+ ///
+ /// In en, this message translates to:
+ /// **'Failed to delete message'**
+ String get errorDeleteMessageFailedTitle;
+
/// Option to mark a specific topic as read in the action sheet.
///
/// In en, this message translates to:
diff --git a/lib/generated/l10n/zulip_localizations_ar.dart b/lib/generated/l10n/zulip_localizations_ar.dart
index 133476376f..94eda2e92a 100644
--- a/lib/generated/l10n/zulip_localizations_ar.dart
+++ b/lib/generated/l10n/zulip_localizations_ar.dart
@@ -237,6 +237,22 @@ class ZulipLocalizationsAr extends ZulipLocalizations {
@override
String get actionSheetOptionEditMessage => 'Edit message';
+ @override
+ String get actionSheetOptionDeleteMessage => 'Delete message';
+
+ @override
+ String get deleteMessageConfirmationDialogTitle => 'Delete message?';
+
+ @override
+ String get deleteMessageConfirmationDialogMessage =>
+ 'Deleting a message permanently removes it for everyone.';
+
+ @override
+ String get deleteMessageConfirmationDialogConfirmButton => 'Delete';
+
+ @override
+ String get errorDeleteMessageFailedTitle => 'Failed to delete message';
+
@override
String get actionSheetOptionMarkTopicAsRead => 'Mark topic as read';
diff --git a/lib/generated/l10n/zulip_localizations_de.dart b/lib/generated/l10n/zulip_localizations_de.dart
index 4516ff21dd..06fa2595dc 100644
--- a/lib/generated/l10n/zulip_localizations_de.dart
+++ b/lib/generated/l10n/zulip_localizations_de.dart
@@ -244,6 +244,22 @@ class ZulipLocalizationsDe extends ZulipLocalizations {
@override
String get actionSheetOptionEditMessage => 'Nachricht bearbeiten';
+ @override
+ String get actionSheetOptionDeleteMessage => 'Delete message';
+
+ @override
+ String get deleteMessageConfirmationDialogTitle => 'Delete message?';
+
+ @override
+ String get deleteMessageConfirmationDialogMessage =>
+ 'Deleting a message permanently removes it for everyone.';
+
+ @override
+ String get deleteMessageConfirmationDialogConfirmButton => 'Delete';
+
+ @override
+ String get errorDeleteMessageFailedTitle => 'Failed to delete message';
+
@override
String get actionSheetOptionMarkTopicAsRead => 'Thema als gelesen markieren';
diff --git a/lib/generated/l10n/zulip_localizations_en.dart b/lib/generated/l10n/zulip_localizations_en.dart
index 348fc47890..81ceadcb9c 100644
--- a/lib/generated/l10n/zulip_localizations_en.dart
+++ b/lib/generated/l10n/zulip_localizations_en.dart
@@ -237,6 +237,22 @@ class ZulipLocalizationsEn extends ZulipLocalizations {
@override
String get actionSheetOptionEditMessage => 'Edit message';
+ @override
+ String get actionSheetOptionDeleteMessage => 'Delete message';
+
+ @override
+ String get deleteMessageConfirmationDialogTitle => 'Delete message?';
+
+ @override
+ String get deleteMessageConfirmationDialogMessage =>
+ 'Deleting a message permanently removes it for everyone.';
+
+ @override
+ String get deleteMessageConfirmationDialogConfirmButton => 'Delete';
+
+ @override
+ String get errorDeleteMessageFailedTitle => 'Failed to delete message';
+
@override
String get actionSheetOptionMarkTopicAsRead => 'Mark topic as read';
diff --git a/lib/generated/l10n/zulip_localizations_fr.dart b/lib/generated/l10n/zulip_localizations_fr.dart
index 89f8ee317a..daef9d5945 100644
--- a/lib/generated/l10n/zulip_localizations_fr.dart
+++ b/lib/generated/l10n/zulip_localizations_fr.dart
@@ -242,6 +242,22 @@ class ZulipLocalizationsFr extends ZulipLocalizations {
@override
String get actionSheetOptionEditMessage => 'Modifier le message';
+ @override
+ String get actionSheetOptionDeleteMessage => 'Delete message';
+
+ @override
+ String get deleteMessageConfirmationDialogTitle => 'Delete message?';
+
+ @override
+ String get deleteMessageConfirmationDialogMessage =>
+ 'Deleting a message permanently removes it for everyone.';
+
+ @override
+ String get deleteMessageConfirmationDialogConfirmButton => 'Delete';
+
+ @override
+ String get errorDeleteMessageFailedTitle => 'Failed to delete message';
+
@override
String get actionSheetOptionMarkTopicAsRead => 'Marquer le sujet comme lu';
diff --git a/lib/generated/l10n/zulip_localizations_it.dart b/lib/generated/l10n/zulip_localizations_it.dart
index 01080f9809..49f4425797 100644
--- a/lib/generated/l10n/zulip_localizations_it.dart
+++ b/lib/generated/l10n/zulip_localizations_it.dart
@@ -241,6 +241,22 @@ class ZulipLocalizationsIt extends ZulipLocalizations {
@override
String get actionSheetOptionEditMessage => 'Modifica messaggio';
+ @override
+ String get actionSheetOptionDeleteMessage => 'Delete message';
+
+ @override
+ String get deleteMessageConfirmationDialogTitle => 'Delete message?';
+
+ @override
+ String get deleteMessageConfirmationDialogMessage =>
+ 'Deleting a message permanently removes it for everyone.';
+
+ @override
+ String get deleteMessageConfirmationDialogConfirmButton => 'Delete';
+
+ @override
+ String get errorDeleteMessageFailedTitle => 'Failed to delete message';
+
@override
String get actionSheetOptionMarkTopicAsRead =>
'Segna l\'argomento come letto';
diff --git a/lib/generated/l10n/zulip_localizations_ja.dart b/lib/generated/l10n/zulip_localizations_ja.dart
index e652d76c43..48e7d7d423 100644
--- a/lib/generated/l10n/zulip_localizations_ja.dart
+++ b/lib/generated/l10n/zulip_localizations_ja.dart
@@ -233,6 +233,22 @@ class ZulipLocalizationsJa extends ZulipLocalizations {
@override
String get actionSheetOptionEditMessage => 'メッセージを編集';
+ @override
+ String get actionSheetOptionDeleteMessage => 'Delete message';
+
+ @override
+ String get deleteMessageConfirmationDialogTitle => 'Delete message?';
+
+ @override
+ String get deleteMessageConfirmationDialogMessage =>
+ 'Deleting a message permanently removes it for everyone.';
+
+ @override
+ String get deleteMessageConfirmationDialogConfirmButton => 'Delete';
+
+ @override
+ String get errorDeleteMessageFailedTitle => 'Failed to delete message';
+
@override
String get actionSheetOptionMarkTopicAsRead => 'トピックを既読にする';
diff --git a/lib/generated/l10n/zulip_localizations_nb.dart b/lib/generated/l10n/zulip_localizations_nb.dart
index 7ae25c3d52..8ee2e9c4da 100644
--- a/lib/generated/l10n/zulip_localizations_nb.dart
+++ b/lib/generated/l10n/zulip_localizations_nb.dart
@@ -237,6 +237,22 @@ class ZulipLocalizationsNb extends ZulipLocalizations {
@override
String get actionSheetOptionEditMessage => 'Edit message';
+ @override
+ String get actionSheetOptionDeleteMessage => 'Delete message';
+
+ @override
+ String get deleteMessageConfirmationDialogTitle => 'Delete message?';
+
+ @override
+ String get deleteMessageConfirmationDialogMessage =>
+ 'Deleting a message permanently removes it for everyone.';
+
+ @override
+ String get deleteMessageConfirmationDialogConfirmButton => 'Delete';
+
+ @override
+ String get errorDeleteMessageFailedTitle => 'Failed to delete message';
+
@override
String get actionSheetOptionMarkTopicAsRead => 'Mark topic as read';
diff --git a/lib/generated/l10n/zulip_localizations_pl.dart b/lib/generated/l10n/zulip_localizations_pl.dart
index 6624146bc1..7a01bbddf8 100644
--- a/lib/generated/l10n/zulip_localizations_pl.dart
+++ b/lib/generated/l10n/zulip_localizations_pl.dart
@@ -246,6 +246,22 @@ class ZulipLocalizationsPl extends ZulipLocalizations {
@override
String get actionSheetOptionEditMessage => 'Zmień wiadomość';
+ @override
+ String get actionSheetOptionDeleteMessage => 'Delete message';
+
+ @override
+ String get deleteMessageConfirmationDialogTitle => 'Delete message?';
+
+ @override
+ String get deleteMessageConfirmationDialogMessage =>
+ 'Deleting a message permanently removes it for everyone.';
+
+ @override
+ String get deleteMessageConfirmationDialogConfirmButton => 'Delete';
+
+ @override
+ String get errorDeleteMessageFailedTitle => 'Failed to delete message';
+
@override
String get actionSheetOptionMarkTopicAsRead =>
'Oznacz wątek jako przeczytany';
diff --git a/lib/generated/l10n/zulip_localizations_ru.dart b/lib/generated/l10n/zulip_localizations_ru.dart
index 27bf2a8ab3..e94ff097ea 100644
--- a/lib/generated/l10n/zulip_localizations_ru.dart
+++ b/lib/generated/l10n/zulip_localizations_ru.dart
@@ -248,6 +248,22 @@ class ZulipLocalizationsRu extends ZulipLocalizations {
@override
String get actionSheetOptionEditMessage => 'Редактировать сообщение';
+ @override
+ String get actionSheetOptionDeleteMessage => 'Delete message';
+
+ @override
+ String get deleteMessageConfirmationDialogTitle => 'Delete message?';
+
+ @override
+ String get deleteMessageConfirmationDialogMessage =>
+ 'Deleting a message permanently removes it for everyone.';
+
+ @override
+ String get deleteMessageConfirmationDialogConfirmButton => 'Delete';
+
+ @override
+ String get errorDeleteMessageFailedTitle => 'Failed to delete message';
+
@override
String get actionSheetOptionMarkTopicAsRead =>
'Отметить тему как прочитанную';
diff --git a/lib/generated/l10n/zulip_localizations_sk.dart b/lib/generated/l10n/zulip_localizations_sk.dart
index fe2a658c52..4201d37be2 100644
--- a/lib/generated/l10n/zulip_localizations_sk.dart
+++ b/lib/generated/l10n/zulip_localizations_sk.dart
@@ -238,6 +238,22 @@ class ZulipLocalizationsSk extends ZulipLocalizations {
@override
String get actionSheetOptionEditMessage => 'Edit message';
+ @override
+ String get actionSheetOptionDeleteMessage => 'Delete message';
+
+ @override
+ String get deleteMessageConfirmationDialogTitle => 'Delete message?';
+
+ @override
+ String get deleteMessageConfirmationDialogMessage =>
+ 'Deleting a message permanently removes it for everyone.';
+
+ @override
+ String get deleteMessageConfirmationDialogConfirmButton => 'Delete';
+
+ @override
+ String get errorDeleteMessageFailedTitle => 'Failed to delete message';
+
@override
String get actionSheetOptionMarkTopicAsRead => 'Mark topic as read';
diff --git a/lib/generated/l10n/zulip_localizations_sl.dart b/lib/generated/l10n/zulip_localizations_sl.dart
index 9bb079a775..b3ccfb0a82 100644
--- a/lib/generated/l10n/zulip_localizations_sl.dart
+++ b/lib/generated/l10n/zulip_localizations_sl.dart
@@ -241,6 +241,22 @@ class ZulipLocalizationsSl extends ZulipLocalizations {
@override
String get actionSheetOptionEditMessage => 'Uredi sporočilo';
+ @override
+ String get actionSheetOptionDeleteMessage => 'Delete message';
+
+ @override
+ String get deleteMessageConfirmationDialogTitle => 'Delete message?';
+
+ @override
+ String get deleteMessageConfirmationDialogMessage =>
+ 'Deleting a message permanently removes it for everyone.';
+
+ @override
+ String get deleteMessageConfirmationDialogConfirmButton => 'Delete';
+
+ @override
+ String get errorDeleteMessageFailedTitle => 'Failed to delete message';
+
@override
String get actionSheetOptionMarkTopicAsRead => 'Označi temo kot prebrano';
diff --git a/lib/generated/l10n/zulip_localizations_uk.dart b/lib/generated/l10n/zulip_localizations_uk.dart
index 91fbfc57c8..292f16146c 100644
--- a/lib/generated/l10n/zulip_localizations_uk.dart
+++ b/lib/generated/l10n/zulip_localizations_uk.dart
@@ -246,6 +246,22 @@ class ZulipLocalizationsUk extends ZulipLocalizations {
@override
String get actionSheetOptionEditMessage => 'Редагувати повідомлення';
+ @override
+ String get actionSheetOptionDeleteMessage => 'Delete message';
+
+ @override
+ String get deleteMessageConfirmationDialogTitle => 'Delete message?';
+
+ @override
+ String get deleteMessageConfirmationDialogMessage =>
+ 'Deleting a message permanently removes it for everyone.';
+
+ @override
+ String get deleteMessageConfirmationDialogConfirmButton => 'Delete';
+
+ @override
+ String get errorDeleteMessageFailedTitle => 'Failed to delete message';
+
@override
String get actionSheetOptionMarkTopicAsRead => 'Позначити тему як прочитану';
diff --git a/lib/generated/l10n/zulip_localizations_zh.dart b/lib/generated/l10n/zulip_localizations_zh.dart
index 46b38ca739..ae0e41822c 100644
--- a/lib/generated/l10n/zulip_localizations_zh.dart
+++ b/lib/generated/l10n/zulip_localizations_zh.dart
@@ -237,6 +237,22 @@ class ZulipLocalizationsZh extends ZulipLocalizations {
@override
String get actionSheetOptionEditMessage => 'Edit message';
+ @override
+ String get actionSheetOptionDeleteMessage => 'Delete message';
+
+ @override
+ String get deleteMessageConfirmationDialogTitle => 'Delete message?';
+
+ @override
+ String get deleteMessageConfirmationDialogMessage =>
+ 'Deleting a message permanently removes it for everyone.';
+
+ @override
+ String get deleteMessageConfirmationDialogConfirmButton => 'Delete';
+
+ @override
+ String get errorDeleteMessageFailedTitle => 'Failed to delete message';
+
@override
String get actionSheetOptionMarkTopicAsRead => 'Mark topic as read';
diff --git a/lib/widgets/action_sheet.dart b/lib/widgets/action_sheet.dart
index ce1be46000..8191f58069 100644
--- a/lib/widgets/action_sheet.dart
+++ b/lib/widgets/action_sheet.dart
@@ -340,6 +340,7 @@ abstract class ActionSheetMenuItemButton extends StatelessWidget {
IconData get icon;
String label(ZulipLocalizations zulipLocalizations);
+ bool get destructive => false;
/// Called when the button is pressed, after dismissing the action sheet.
///
@@ -376,6 +377,9 @@ abstract class ActionSheetMenuItemButton extends StatelessWidget {
Widget build(BuildContext context) {
final zulipLocalizations = ZulipLocalizations.of(context);
return ZulipMenuItemButton(
+ style: destructive
+ ? ZulipMenuItemButtonStyle.menuDestructive
+ : ZulipMenuItemButtonStyle.menu,
icon: icon,
label: label(zulipLocalizations),
onPressed: () => _handlePressed(context),
@@ -643,7 +647,7 @@ class UnsubscribeButton extends ActionSheetMenuItemButton {
final dialog = showSuggestedActionDialog(context: pageContext,
title: zulipLocalizations.unsubscribeConfirmationDialogTitle(subscription.name),
message: zulipLocalizations.unsubscribeConfirmationDialogMessageMaybeCannotResubscribe,
- // TODO(#1032) "destructive" style for action button
+ destructiveActionButton: true,
actionButtonText: zulipLocalizations.unsubscribeConfirmationDialogConfirmButton);
if (await dialog.result != true) return;
if (!pageContext.mounted) return;
@@ -1025,6 +1029,8 @@ class CopyTopicLinkButton extends ActionSheetMenuItemButton {
///
/// Must have a [MessageListPage] ancestor.
void showMessageActionSheet({required BuildContext context, required Message message}) {
+ final now = ZulipBinding.instance.utcNow();
+
final pageContext = PageRoot.contextOf(context);
final store = PerAccountStoreWidget.of(pageContext);
@@ -1049,30 +1055,34 @@ void showMessageActionSheet({required BuildContext context, required Message mes
final isSenderMuted = store.isUserMuted(message.senderId);
- final optionButtons = [
- if (popularEmojiLoaded)
- ReactionButtons(message: message, pageContext: pageContext),
- if (hasReactions)
- ViewReactionsButton(message: message, pageContext: pageContext),
- if (readReceiptsEnabled)
- ViewReadReceiptsButton(message: message, pageContext: pageContext),
- StarButton(message: message, pageContext: pageContext),
- if (isComposeBoxOffered)
- QuoteAndReplyButton(message: message, pageContext: pageContext),
- if (showMarkAsUnreadButton)
- MarkAsUnreadButton(message: message, pageContext: pageContext),
- if (isSenderMuted)
- // The message must have been revealed in order to open this action sheet.
- UnrevealMutedMessageButton(message: message, pageContext: pageContext),
- CopyMessageTextButton(message: message, pageContext: pageContext),
- CopyMessageLinkButton(message: message, pageContext: pageContext),
- ShareButton(message: message, pageContext: pageContext),
- if (_getShouldShowEditButton(pageContext, message))
- EditButton(message: message, pageContext: pageContext),
+ final buttonSections = [
+ [
+ if (popularEmojiLoaded)
+ ReactionButtons(message: message, pageContext: pageContext),
+ if (hasReactions)
+ ViewReactionsButton(message: message, pageContext: pageContext),
+ if (readReceiptsEnabled)
+ ViewReadReceiptsButton(message: message, pageContext: pageContext),
+ StarButton(message: message, pageContext: pageContext),
+ if (isComposeBoxOffered)
+ QuoteAndReplyButton(message: message, pageContext: pageContext),
+ if (showMarkAsUnreadButton)
+ MarkAsUnreadButton(message: message, pageContext: pageContext),
+ if (isSenderMuted)
+ // The message must have been revealed in order to open this action sheet.
+ UnrevealMutedMessageButton(message: message, pageContext: pageContext),
+ CopyMessageTextButton(message: message, pageContext: pageContext),
+ CopyMessageLinkButton(message: message, pageContext: pageContext),
+ ShareButton(message: message, pageContext: pageContext),
+ if (_getShouldShowEditButton(pageContext, message))
+ EditButton(message: message, pageContext: pageContext),
+ ],
+ if (store.selfCanDeleteMessage(message.id, atDate: now))
+ [DeleteMessageButton(message: message, pageContext: pageContext)],
];
_showActionSheet(pageContext,
- buttonSections: [optionButtons],
+ buttonSections: buttonSections,
header: _MessageActionSheetHeader(message: message));
}
@@ -1602,3 +1612,49 @@ class EditButton extends MessageActionSheetMenuItemButton {
composeBoxState.startEditInteraction(message.id);
}
}
+
+class DeleteMessageButton extends MessageActionSheetMenuItemButton {
+ DeleteMessageButton({super.key, required super.message, required super.pageContext});
+
+ @override
+ IconData get icon => ZulipIcons.trash;
+
+ @override
+ bool get destructive => true;
+
+ @override
+ String label(ZulipLocalizations zulipLocalizations) =>
+ zulipLocalizations.actionSheetOptionDeleteMessage;
+
+ @override void onPressed() async {
+ final zulipLocalizations = ZulipLocalizations.of(pageContext);
+
+ final dialog = showSuggestedActionDialog(context: pageContext,
+ title: zulipLocalizations.deleteMessageConfirmationDialogTitle,
+ message: zulipLocalizations.deleteMessageConfirmationDialogMessage,
+ destructiveActionButton: true,
+ actionButtonText: zulipLocalizations.deleteMessageConfirmationDialogConfirmButton,
+ );
+ if (await dialog.result != true) return;
+ if (!pageContext.mounted) return;
+
+ final connection = PerAccountStoreWidget.of(pageContext).connection;
+ try {
+ await deleteMessage(connection, messageId: message.id);
+ } catch (e) {
+ if (!pageContext.mounted) return;
+
+ String? errorMessage;
+ switch (e) {
+ case ZulipApiException():
+ errorMessage = e.message;
+ // TODO(#741) specific messages for common errors, like network errors
+ // (support with reusable code)
+ default:
+ }
+
+ final title = ZulipLocalizations.of(pageContext).errorDeleteMessageFailedTitle;
+ showErrorDialog(context: pageContext, title: title, message: errorMessage);
+ }
+ }
+}
diff --git a/lib/widgets/app.dart b/lib/widgets/app.dart
index a3aa1053a8..69dd62d7c9 100644
--- a/lib/widgets/app.dart
+++ b/lib/widgets/app.dart
@@ -347,7 +347,7 @@ class ChooseAccountPage extends StatelessWidget {
final dialog = showSuggestedActionDialog(context: context,
title: zulipLocalizations.logOutConfirmationDialogTitle,
message: zulipLocalizations.logOutConfirmationDialogMessage,
- // TODO(#1032) "destructive" style for action button
+ destructiveActionButton: true,
actionButtonText: zulipLocalizations.logOutConfirmationDialogConfirmButton);
if (await dialog.result == true) {
if (!context.mounted) return;
diff --git a/lib/widgets/button.dart b/lib/widgets/button.dart
index 86997c08f2..ef8d0fa07d 100644
--- a/lib/widgets/button.dart
+++ b/lib/widgets/button.dart
@@ -327,7 +327,7 @@ class MenuButtonsShape extends StatelessWidget {
class ZulipMenuItemButton extends StatelessWidget {
const ZulipMenuItemButton({
super.key,
- this.style = ZulipMenuItemButtonStyle.menu,
+ required this.style,
required this.label,
this.subLabel,
required this.onPressed,
@@ -350,7 +350,8 @@ class ZulipMenuItemButton extends StatelessWidget {
final Widget? toggle;
double get itemSpacingAndEndPadding => switch (style) {
- ZulipMenuItemButtonStyle.menu => 16,
+ ZulipMenuItemButtonStyle.menu
+ || ZulipMenuItemButtonStyle.menuDestructive => 16,
ZulipMenuItemButtonStyle.list => 12,
};
@@ -373,6 +374,11 @@ class ZulipMenuItemButton extends StatelessWidget {
WidgetState.pressed: designVariables.contextMenuItemBg.withFadedAlpha(0.20),
~WidgetState.pressed: designVariables.contextMenuItemBg.withFadedAlpha(0.12),
});
+ case ZulipMenuItemButtonStyle.menuDestructive:
+ return WidgetStateColor.fromMap({
+ WidgetState.pressed: designVariables.contextMenuItemBgDanger.withFadedAlpha(0.20),
+ ~WidgetState.pressed: designVariables.contextMenuItemBgDanger.withFadedAlpha(0.12),
+ });
case ZulipMenuItemButtonStyle.list:
return WidgetStateColor.fromMap({
WidgetState.pressed: designVariables.listMenuItemBg.withFadedAlpha(0.7),
@@ -384,13 +390,15 @@ class ZulipMenuItemButton extends StatelessWidget {
Color _labelColor(DesignVariables designVariables) {
return switch (style) {
ZulipMenuItemButtonStyle.menu => designVariables.contextMenuItemText,
+ ZulipMenuItemButtonStyle.menuDestructive => designVariables.contextMenuItemTextDanger,
ZulipMenuItemButtonStyle.list => designVariables.listMenuItemText,
};
}
double _labelWght() {
return switch (style) {
- ZulipMenuItemButtonStyle.menu => 600,
+ ZulipMenuItemButtonStyle.menu
+ || ZulipMenuItemButtonStyle.menuDestructive => 600,
ZulipMenuItemButtonStyle.list => 500,
};
}
@@ -398,6 +406,7 @@ class ZulipMenuItemButton extends StatelessWidget {
Color _iconColor(DesignVariables designVariables) {
return switch (style) {
ZulipMenuItemButtonStyle.menu => designVariables.contextMenuItemIcon,
+ ZulipMenuItemButtonStyle.menuDestructive => designVariables.contextMenuItemIconDanger,
ZulipMenuItemButtonStyle.list => designVariables.listMenuItemIcon,
};
}
@@ -468,6 +477,12 @@ enum ZulipMenuItemButtonStyle {
/// https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=3302-20443&m=dev
menu,
+ /// The red, destructive variant of [menu].
+ ///
+ /// See Figma:
+ /// https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=6329-127234&m=dev
+ menuDestructive,
+
/// The gray "list button" component in Figma, with 12px end padding.
///
/// See Figma:
diff --git a/lib/widgets/compose_box.dart b/lib/widgets/compose_box.dart
index 76a4e36411..71880b1dc2 100644
--- a/lib/widgets/compose_box.dart
+++ b/lib/widgets/compose_box.dart
@@ -2024,7 +2024,7 @@ class _ComposeBoxState extends State with PerAccountStoreAwareStateM
final dialog = showSuggestedActionDialog(context: context,
title: zulipLocalizations.discardDraftConfirmationDialogTitle,
message: dialogMessage,
- // TODO(#1032) "destructive" style for action button
+ destructiveActionButton: true,
actionButtonText: zulipLocalizations.discardDraftConfirmationDialogConfirmButton);
if (await dialog.result != true) return true;
}
diff --git a/lib/widgets/dialog.dart b/lib/widgets/dialog.dart
index c5e2e6e562..fb93a3d6cc 100644
--- a/lib/widgets/dialog.dart
+++ b/lib/widgets/dialog.dart
@@ -10,7 +10,15 @@ import 'content.dart';
import 'store.dart';
/// A platform-appropriate action for [AlertDialog.adaptive]'s [actions] param.
-Widget _adaptiveAction({required VoidCallback onPressed, required String text}) {
+///
+/// [isDefaultAction] and [isDestructiveAction] are ignored on Android
+/// because Material Design doesn't specify corresponding styles.
+Widget _adaptiveAction({
+ required VoidCallback onPressed,
+ required bool isDefaultAction,
+ bool isDestructiveAction = false,
+ required String text,
+}) {
switch (defaultTargetPlatform) {
case TargetPlatform.android:
case TargetPlatform.fuchsia:
@@ -30,7 +38,11 @@ Widget _adaptiveAction({required VoidCallback onPressed, required String text})
case TargetPlatform.iOS:
case TargetPlatform.macOS:
- return CupertinoDialogAction(onPressed: onPressed, child: Text(text));
+ return CupertinoDialogAction(
+ onPressed: onPressed,
+ isDefaultAction: isDefaultAction,
+ isDestructiveAction: isDestructiveAction,
+ child: Text(text));
}
}
@@ -112,9 +124,11 @@ DialogStatus showErrorDialog({
if (learnMoreButtonUrl != null)
_adaptiveAction(
onPressed: () => PlatformActions.launchUrl(context, learnMoreButtonUrl),
+ isDefaultAction: false,
text: zulipLocalizations.errorDialogLearnMore),
_adaptiveAction(
onPressed: () => Navigator.pop(context),
+ isDefaultAction: true,
text: zulipLocalizations.errorDialogContinue),
]));
return DialogStatus(future);
@@ -133,6 +147,7 @@ DialogStatus showSuggestedActionDialog({
required String title,
required String message,
required String? actionButtonText,
+ bool destructiveActionButton = false,
}) {
final zulipLocalizations = ZulipLocalizations.of(context);
final future = showDialog(
@@ -143,9 +158,12 @@ DialogStatus showSuggestedActionDialog({
actions: [
_adaptiveAction(
onPressed: () => Navigator.pop(context, null),
+ isDefaultAction: false,
text: zulipLocalizations.dialogCancel),
_adaptiveAction(
onPressed: () => Navigator.pop(context, true),
+ isDefaultAction: true,
+ isDestructiveAction: destructiveActionButton,
text: actionButtonText ?? zulipLocalizations.dialogContinue),
]));
return DialogStatus(future);
@@ -213,6 +231,7 @@ class UpgradeWelcomeDialog extends StatelessWidget {
actions: [
_adaptiveAction(
onPressed: () => Navigator.pop(context),
+ isDefaultAction: true,
text: zulipLocalizations.upgradeWelcomeDialogDismiss)
]);
}
diff --git a/lib/widgets/icons.dart b/lib/widgets/icons.dart
index 54ab74eed3..c9eb68361b 100644
--- a/lib/widgets/icons.dart
+++ b/lib/widgets/icons.dart
@@ -189,11 +189,14 @@ abstract final class ZulipIcons {
/// The Zulip custom icon "topics".
static const IconData topics = IconData(0xf137, fontFamily: "Zulip Icons");
+ /// The Zulip custom icon "trash".
+ static const IconData trash = IconData(0xf138, fontFamily: "Zulip Icons");
+
/// The Zulip custom icon "two_person".
- static const IconData two_person = IconData(0xf138, fontFamily: "Zulip Icons");
+ static const IconData two_person = IconData(0xf139, fontFamily: "Zulip Icons");
/// The Zulip custom icon "unmute".
- static const IconData unmute = IconData(0xf139, fontFamily: "Zulip Icons");
+ static const IconData unmute = IconData(0xf13a, fontFamily: "Zulip Icons");
// END GENERATED ICON DATA
}
diff --git a/lib/widgets/subscription_list.dart b/lib/widgets/subscription_list.dart
index 0feaeeaa52..ad286cf71f 100644
--- a/lib/widgets/subscription_list.dart
+++ b/lib/widgets/subscription_list.dart
@@ -185,6 +185,7 @@ class _SubscriptionListPageBodyState extends State wit
sliver: SliverToBoxAdapter(
child: MenuButtonsShape(buttons: [
ZulipMenuItemButton(
+ style: ZulipMenuItemButtonStyle.menu,
label: zulipLocalizations.navButtonAllChannels,
icon: ZulipIcons.chevron_right,
onPressed: () => Navigator.push(context,
diff --git a/lib/widgets/theme.dart b/lib/widgets/theme.dart
index 8680640f6b..44517ab2c3 100644
--- a/lib/widgets/theme.dart
+++ b/lib/widgets/theme.dart
@@ -161,10 +161,13 @@ class DesignVariables extends ThemeExtension {
composeBoxBg: const Color(0xffffffff),
contextMenuCancelText: const Color(0xff222222),
contextMenuItemBg: const Color(0xff6159e1),
+ contextMenuItemBgDanger: const Color(0xffc0070a), // TODO(#831) red/550
contextMenuItemIcon: const Color(0xff4f42c9),
+ contextMenuItemIconDanger: const Color(0xffac0508), // TODO(#831) red/600
contextMenuItemLabel: const Color(0xff242631),
contextMenuItemMeta: const Color(0xff626573),
contextMenuItemText: const Color(0xff381da7),
+ contextMenuItemTextDanger: const Color(0xffac0508), // TODO(#831) red/600
editorButtonPressedBg: Colors.black.withValues(alpha: 0.06),
fabBg: const Color(0xff6e69f3),
fabBgPressed: const Color(0xff6159e1),
@@ -252,10 +255,13 @@ class DesignVariables extends ThemeExtension {
composeBoxBg: const Color(0xff0f0f0f),
contextMenuCancelText: const Color(0xffffffff).withValues(alpha: 0.75),
contextMenuItemBg: const Color(0xff7977fe),
+ contextMenuItemBgDanger: const Color(0xffe1392e), // TODO(#831) red/450
contextMenuItemIcon: const Color(0xff9398fd),
+ contextMenuItemIconDanger: const Color(0xfffd7465), // TODO(#831) red/300
contextMenuItemLabel: const Color(0xffdfe1e8),
contextMenuItemMeta: const Color(0xff9194a3),
contextMenuItemText: const Color(0xff9398fd),
+ contextMenuItemTextDanger: const Color(0xfffd7465), // TODO(#831) red/300
editorButtonPressedBg: Colors.white.withValues(alpha: 0.06),
fabBg: const Color(0xff4f42c9),
fabBgPressed: const Color(0xff4331b8),
@@ -352,10 +358,13 @@ class DesignVariables extends ThemeExtension {
required this.composeBoxBg,
required this.contextMenuCancelText,
required this.contextMenuItemBg,
+ required this.contextMenuItemBgDanger,
required this.contextMenuItemIcon,
+ required this.contextMenuItemIconDanger,
required this.contextMenuItemLabel,
required this.contextMenuItemMeta,
required this.contextMenuItemText,
+ required this.contextMenuItemTextDanger,
required this.editorButtonPressedBg,
required this.foreground,
required this.fabBg,
@@ -443,10 +452,13 @@ class DesignVariables extends ThemeExtension {
final Color composeBoxBg;
final Color contextMenuCancelText;
final Color contextMenuItemBg;
+ final Color contextMenuItemBgDanger;
final Color contextMenuItemIcon;
+ final Color contextMenuItemIconDanger;
final Color contextMenuItemLabel;
final Color contextMenuItemMeta;
final Color contextMenuItemText;
+ final Color contextMenuItemTextDanger;
final Color editorButtonPressedBg;
final Color fabBg;
final Color fabBgPressed;
@@ -529,10 +541,13 @@ class DesignVariables extends ThemeExtension {
Color? composeBoxBg,
Color? contextMenuCancelText,
Color? contextMenuItemBg,
+ Color? contextMenuItemBgDanger,
Color? contextMenuItemIcon,
+ Color? contextMenuItemIconDanger,
Color? contextMenuItemLabel,
Color? contextMenuItemMeta,
Color? contextMenuItemText,
+ Color? contextMenuItemTextDanger,
Color? editorButtonPressedBg,
Color? fabBg,
Color? fabBgPressed,
@@ -610,10 +625,13 @@ class DesignVariables extends ThemeExtension {
composeBoxBg: composeBoxBg ?? this.composeBoxBg,
contextMenuCancelText: contextMenuCancelText ?? this.contextMenuCancelText,
contextMenuItemBg: contextMenuItemBg ?? this.contextMenuItemBg,
+ contextMenuItemBgDanger: contextMenuItemBgDanger ?? this.contextMenuItemBgDanger,
contextMenuItemIcon: contextMenuItemIcon ?? this.contextMenuItemIcon,
+ contextMenuItemIconDanger: contextMenuItemIconDanger ?? this.contextMenuItemIconDanger,
contextMenuItemLabel: contextMenuItemLabel ?? this.contextMenuItemLabel,
contextMenuItemMeta: contextMenuItemMeta ?? this.contextMenuItemMeta,
contextMenuItemText: contextMenuItemText ?? this.contextMenuItemText,
+ contextMenuItemTextDanger: contextMenuItemTextDanger ?? this.contextMenuItemTextDanger,
editorButtonPressedBg: editorButtonPressedBg ?? this.editorButtonPressedBg,
foreground: foreground ?? this.foreground,
fabBg: fabBg ?? this.fabBg,
@@ -698,10 +716,13 @@ class DesignVariables extends ThemeExtension {
composeBoxBg: Color.lerp(composeBoxBg, other.composeBoxBg, t)!,
contextMenuCancelText: Color.lerp(contextMenuCancelText, other.contextMenuCancelText, t)!,
contextMenuItemBg: Color.lerp(contextMenuItemBg, other.contextMenuItemBg, t)!,
+ contextMenuItemBgDanger: Color.lerp(contextMenuItemBgDanger, other.contextMenuItemBgDanger, t)!,
contextMenuItemIcon: Color.lerp(contextMenuItemIcon, other.contextMenuItemIcon, t)!,
+ contextMenuItemIconDanger: Color.lerp(contextMenuItemIconDanger, other.contextMenuItemIconDanger, t)!,
contextMenuItemLabel: Color.lerp(contextMenuItemLabel, other.contextMenuItemLabel, t)!,
contextMenuItemMeta: Color.lerp(contextMenuItemMeta, other.contextMenuItemMeta, t)!,
contextMenuItemText: Color.lerp(contextMenuItemText, other.contextMenuItemText, t)!,
+ contextMenuItemTextDanger: Color.lerp(contextMenuItemTextDanger, other.contextMenuItemTextDanger, t)!,
editorButtonPressedBg: Color.lerp(editorButtonPressedBg, other.editorButtonPressedBg, t)!,
foreground: Color.lerp(foreground, other.foreground, t)!,
fabBg: Color.lerp(fabBg, other.fabBg, t)!,
diff --git a/test/api/route/messages_test.dart b/test/api/route/messages_test.dart
index 121e0ef282..21d4a7de3d 100644
--- a/test/api/route/messages_test.dart
+++ b/test/api/route/messages_test.dart
@@ -450,6 +450,18 @@ void main() {
});
});
+ group('updateMessage', () {
+ test('smoke', () {
+ return FakeApiConnection.with_((connection) async {
+ connection.prepare(json: {});
+ await deleteMessage(connection, messageId: 123321);
+ check(connection.takeRequests()).single.isA()
+ ..method.equals('DELETE')
+ ..url.path.equals('/api/v1/messages/123321');
+ });
+ });
+ });
+
group('uploadFile', () {
Future checkUploadFile(FakeApiConnection connection, {
required List> content,
diff --git a/test/example_data.dart b/test/example_data.dart
index b9f9d87f01..9a9eb1099e 100644
--- a/test/example_data.dart
+++ b/test/example_data.dart
@@ -1285,6 +1285,13 @@ InitialSnapshot initialSnapshot({
List? realmNonActiveUsers,
List? crossRealmBots,
}) {
+ if (realmDeleteOwnMessagePolicy == null) {
+ // Set a default for realmCanDeleteOwnMessageGroup, but only if we're
+ // trying to simulate a modern server without realmDeleteOwnMessagePolicy.
+ realmCanDeleteOwnMessageGroup ??= GroupSettingValueNamed(nobodyGroup.id);
+ }
+ assert((realmCanDeleteOwnMessageGroup != null) ^ (realmDeleteOwnMessagePolicy != null));
+
return InitialSnapshot(
queueId: queueId ?? '1:2345',
lastEventId: lastEventId ?? -1,
@@ -1320,7 +1327,6 @@ InitialSnapshot initialSnapshot({
userTopics: userTopics,
// no default; allow `null` to simulate servers without this
realmCanDeleteAnyMessageGroup: realmCanDeleteAnyMessageGroup,
- // no default; allow `null` to simulate servers without this
realmCanDeleteOwnMessageGroup: realmCanDeleteOwnMessageGroup,
realmDeleteOwnMessagePolicy: realmDeleteOwnMessagePolicy,
realmWildcardMentionPolicy: realmWildcardMentionPolicy ?? RealmWildcardMentionPolicy.everyone,
diff --git a/test/widgets/action_sheet_test.dart b/test/widgets/action_sheet_test.dart
index 8f4d614220..9866b9dc10 100644
--- a/test/widgets/action_sheet_test.dart
+++ b/test/widgets/action_sheet_test.dart
@@ -64,6 +64,7 @@ Future setupToMessageActionSheet(WidgetTester tester, {
List? mutedUserIds,
bool? realmAllowMessageEditing,
int? realmMessageContentEditLimitSeconds,
+ bool hasDeletePermission = true,
bool? realmEnableReadReceipts,
bool shouldSetServerEmojiData = true,
bool useLegacyServerEmojiData = false,
@@ -74,6 +75,7 @@ Future setupToMessageActionSheet(WidgetTester tester, {
assert(narrow.containsMessage(message)!);
selfUser ??= eg.selfUser;
+ assert(!(hasDeletePermission && selfUser.role == UserRole.guest));
final selfAccount = eg.account(user: selfUser);
await testBinding.globalStore.add(
selfAccount,
@@ -82,6 +84,10 @@ Future setupToMessageActionSheet(WidgetTester tester, {
realmAllowMessageEditing: realmAllowMessageEditing,
realmMessageContentEditLimitSeconds: realmMessageContentEditLimitSeconds,
realmEnableReadReceipts: realmEnableReadReceipts,
+ realmCanDeleteAnyMessageGroup: hasDeletePermission
+ ? eg.groupSetting(members: [selfUser.userId])
+ : eg.groupSetting(members: []),
+ realmCanDeleteOwnMessageGroup: eg.groupSetting(members: []),
));
store = await testBinding.globalStore.perAccount(selfAccount.id);
await store.addUsers([
@@ -117,6 +123,9 @@ Future setupToMessageActionSheet(WidgetTester tester, {
// global store, per-account store, and message list get loaded
await tester.pumpAndSettle();
+ check(store.selfCanDeleteMessage(message.id, atDate: testBinding.utcNow()))
+ .equals(hasDeletePermission);
+
await beforeLongPress?.call();
// Request the message action sheet.
@@ -576,6 +585,7 @@ void main() {
final (unsubscribeButton, cancelButton) = checkSuggestedActionDialog(tester,
expectedTitle: 'Unsubscribe from ${channel.name}?',
expectedMessage: 'Once you leave this channel, you might not be able to rejoin.',
+ expectDestructiveActionButton: true,
expectedActionButtonText: 'Unsubscribe');
await tester.tap(find.byWidget(unsubscribeButton));
await tester.pump(Duration.zero);
@@ -1160,6 +1170,10 @@ void main() {
});
group('message action sheet', () {
+ final actionSheetFinder = find.byType(BottomSheet);
+ Finder findButtonForLabel(String label) =>
+ find.descendant(of: actionSheetFinder, matching: find.text(label));
+
group('header', () {
void checkSenderAndTimestampShown(WidgetTester tester, {required int senderId}) {
check(find.descendant(
@@ -2212,6 +2226,95 @@ void main() {
});
});
+ group('DeleteMessageButton', () {
+ final findButton = findButtonForLabel('Delete message');
+
+ group('visibility', () {
+ testWidgets('shown when user has permission', (tester) async {
+ final message = eg.streamMessage(flags: []);
+ await setupToMessageActionSheet(tester,
+ hasDeletePermission: true,
+ message: message, narrow: TopicNarrow.ofMessage(message));
+
+ check(findButton).findsOne();
+ });
+
+ testWidgets('not shown when user does not have permission', (tester) async {
+ final message = eg.streamMessage(flags: []);
+ await setupToMessageActionSheet(tester,
+ hasDeletePermission: false,
+ message: message, narrow: TopicNarrow.ofMessage(message));
+
+ check(findButton).findsNothing();
+ });
+ });
+
+ Future tapButton(WidgetTester tester, {bool starred = false}) async {
+ await tester.ensureVisible(findButton);
+ await tester.tap(findButton);
+ await tester.pump(); // [MenuItemButton.onPressed] called in a post-frame callback: flutter/flutter@e4a39fa2e
+ }
+
+ (Widget, Widget) checkConfirmation(WidgetTester tester) =>
+ checkSuggestedActionDialog(tester,
+ expectedTitle: 'Delete message?',
+ expectedMessage: 'Deleting a message permanently removes it for everyone.',
+ expectDestructiveActionButton: true,
+ expectedActionButtonText: 'Delete');
+
+ testWidgets('smoke', (tester) async {
+ final message = eg.streamMessage(flags: []);
+ await setupToMessageActionSheet(tester,
+ message: message, narrow: TopicNarrow.ofMessage(message));
+
+ await tapButton(tester);
+ await tester.pump();
+
+ final (deleteButton, cancelButton) = checkConfirmation(tester);
+ connection.prepare(json: {});
+ await tester.tap(find.byWidget(deleteButton));
+ await tester.pump(Duration.zero);
+
+ check(connection.lastRequest).isA()
+ ..method.equals('DELETE')
+ ..url.path.equals('/api/v1/messages/${message.id}')
+ ..bodyFields.deepEquals({});
+ });
+
+ testWidgets('cancel confirmation dialog', (tester) async {
+ final message = eg.streamMessage(flags: []);
+ await setupToMessageActionSheet(tester,
+ message: message, narrow: TopicNarrow.ofMessage(message));
+
+ connection.takeRequests();
+
+ await tapButton(tester);
+ await tester.pump();
+
+ final (deleteButton, cancelButton) = checkConfirmation(tester);
+ await tester.tap(find.byWidget(cancelButton));
+ await tester.pumpAndSettle();
+
+ check(connection.lastRequest).isNull();
+ });
+
+ testWidgets('request fails', (tester) async {
+ final message = eg.streamMessage(flags: []);
+ await setupToMessageActionSheet(tester,
+ message: message, narrow: TopicNarrow.ofMessage(message));
+
+ await tapButton(tester);
+ await tester.pump();
+
+ final (deleteButton, cancelButton) = checkConfirmation(tester);
+ connection.prepare(apiException: eg.apiBadRequest());
+ await tester.tap(find.byWidget(deleteButton));
+ await tester.pump(Duration.zero);
+
+ checkErrorDialog(tester, expectedTitle: 'Failed to delete message');
+ });
+ });
+
group('MessageActionSheetCancelButton', () {
final zulipLocalizations = GlobalLocalizations.zulipLocalizations;
diff --git a/test/widgets/app_test.dart b/test/widgets/app_test.dart
index efebcc34fa..f486bf168d 100644
--- a/test/widgets/app_test.dart
+++ b/test/widgets/app_test.dart
@@ -382,6 +382,7 @@ void main() {
return checkSuggestedActionDialog(tester,
expectedTitle: 'Log out?',
expectedMessage: 'To use this account in the future, you will have to re-enter the URL for your organization and your account information.',
+ expectDestructiveActionButton: true,
expectedActionButtonText: 'Log out');
}
diff --git a/test/widgets/compose_box_test.dart b/test/widgets/compose_box_test.dart
index 816ade42a7..3bd07653c2 100644
--- a/test/widgets/compose_box_test.dart
+++ b/test/widgets/compose_box_test.dart
@@ -1645,6 +1645,7 @@ void main() {
final (actionButton, cancelButton) = checkSuggestedActionDialog(tester,
expectedTitle: 'Discard the message you’re writing?',
expectedMessage: expectedMessage,
+ expectDestructiveActionButton: true,
expectedActionButtonText: 'Discard');
if (shouldContinue) {
await tester.tap(find.byWidget(actionButton));
diff --git a/test/widgets/dialog_checks.dart b/test/widgets/dialog_checks.dart
index ce7effe2a9..aca3eeffae 100644
--- a/test/widgets/dialog_checks.dart
+++ b/test/widgets/dialog_checks.dart
@@ -100,6 +100,10 @@ void checkNoDialog(WidgetTester tester) {
/// Checks for a suggested-action dialog matching an expected title and message.
/// Fails if none is found.
///
+/// Use [expectDestructiveActionButton] to check whether
+/// the button is "destructive" (see [showSuggestedActionDialog]).
+/// This has no effect on Android because the "destructive" style is iOS-only.
+///
/// On success, returns a Record with the widget's action button first
/// and its cancel button second.
/// Tap the action button by calling `tester.tap(find.byWidget(actionButton))`.
@@ -107,6 +111,7 @@ void checkNoDialog(WidgetTester tester) {
required String expectedTitle,
required String expectedMessage,
String? expectedActionButtonText,
+ bool expectDestructiveActionButton = false,
}) {
switch (defaultTargetPlatform) {
case TargetPlatform.android:
@@ -133,8 +138,13 @@ void checkNoDialog(WidgetTester tester) {
tester.widget(find.descendant(matchRoot: true,
of: find.byWidget(dialog.content!), matching: find.text(expectedMessage)));
- final actionButton = tester.widget(find.descendant(of: find.byWidget(dialog),
- matching: find.widgetWithText(CupertinoDialogAction, expectedActionButtonText ?? 'Continue')));
+ final actionButton = tester.widget(
+ find.descendant(
+ of: find.byWidget(dialog),
+ matching: find.widgetWithText(
+ CupertinoDialogAction,
+ expectedActionButtonText ?? 'Continue')));
+ check(actionButton.isDestructiveAction).equals(expectDestructiveActionButton);
final cancelButton = tester.widget(find.descendant(of: find.byWidget(dialog),
matching: find.widgetWithText(CupertinoDialogAction, 'Cancel')));
return (actionButton, cancelButton);