diff --git a/lib/widgets/dialog.dart b/lib/widgets/dialog.dart index 7ff373db6d..2016a899df 100644 --- a/lib/widgets/dialog.dart +++ b/lib/widgets/dialog.dart @@ -1,20 +1,32 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import '../generated/l10n/zulip_localizations.dart'; import 'actions.dart'; -Widget _dialogActionText(String text) { - return Text( - text, - - // As suggested by - // https://api.flutter.dev/flutter/material/AlertDialog/actions.html : - // > It is recommended to set the Text.textAlign to TextAlign.end - // > for the Text within the TextButton, so that buttons whose - // > labels wrap to an extra line align with the overall - // > OverflowBar's alignment within the dialog. - textAlign: TextAlign.end, - ); +/// A platform-appropriate action for [AlertDialog.adaptive]'s [actions] param. +Widget _adaptiveAction({required VoidCallback onPressed, required String text}) { + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + return TextButton( + onPressed: onPressed, + child: Text( + text, + // As suggested by + // https://api.flutter.dev/flutter/material/AlertDialog/actions.html : + // > It is recommended to set the Text.textAlign to TextAlign.end + // > for the Text within the TextButton, so that buttons whose + // > labels wrap to an extra line align with the overall + // > OverflowBar's alignment within the dialog. + textAlign: TextAlign.end)); + case TargetPlatform.iOS: + case TargetPlatform.macOS: + return CupertinoDialogAction(onPressed: onPressed, child: Text(text)); + } } /// Tracks the status of a dialog, in being still open or already closed. @@ -46,17 +58,18 @@ DialogStatus showErrorDialog({ final zulipLocalizations = ZulipLocalizations.of(context); final future = showDialog( context: context, - builder: (BuildContext context) => AlertDialog( + builder: (BuildContext context) => AlertDialog.adaptive( title: Text(title), content: message != null ? SingleChildScrollView(child: Text(message)) : null, actions: [ if (learnMoreButtonUrl != null) - TextButton( + _adaptiveAction( onPressed: () => PlatformActions.launchUrl(context, learnMoreButtonUrl), - child: _dialogActionText(zulipLocalizations.errorDialogLearnMore)), - TextButton( + text: zulipLocalizations.errorDialogLearnMore, + ), + _adaptiveAction( onPressed: () => Navigator.pop(context), - child: _dialogActionText(zulipLocalizations.errorDialogContinue)), + text: zulipLocalizations.errorDialogContinue), ])); return DialogStatus(future); } @@ -71,18 +84,18 @@ void showSuggestedActionDialog({ final zulipLocalizations = ZulipLocalizations.of(context); showDialog( context: context, - builder: (BuildContext context) => AlertDialog( + builder: (BuildContext context) => AlertDialog.adaptive( title: Text(title), content: SingleChildScrollView(child: Text(message)), actions: [ - TextButton( + _adaptiveAction( onPressed: () => Navigator.pop(context), - child: _dialogActionText(zulipLocalizations.dialogCancel)), - TextButton( + text: zulipLocalizations.dialogCancel), + _adaptiveAction( onPressed: () { onActionButtonPress(); Navigator.pop(context); }, - child: _dialogActionText(actionButtonText ?? zulipLocalizations.dialogContinue)), + text: actionButtonText ?? zulipLocalizations.dialogContinue), ])); } diff --git a/test/widgets/action_sheet_test.dart b/test/widgets/action_sheet_test.dart index 8aeeec4eed..5ff3757fd4 100644 --- a/test/widgets/action_sheet_test.dart +++ b/test/widgets/action_sheet_test.dart @@ -273,7 +273,7 @@ void main() { await tester.tap(findButtonForLabel('Mark channel as read')); await tester.pumpAndSettle(); checkRequest(someChannel.streamId); - checkNoErrorDialog(tester); + checkNoDialog(tester); }); testWidgets('request fails', (tester) async { @@ -688,7 +688,7 @@ void main() { await tester.tap(findButtonForLabel('Mark as resolved')); await tester.pumpAndSettle(); - checkNoErrorDialog(tester); + checkNoDialog(tester); checkRequest(message.id, '✔ zulip'); }); @@ -703,7 +703,7 @@ void main() { await tester.tap(findButtonForLabel('Mark as resolved')); await tester.pumpAndSettle(); - checkNoErrorDialog(tester); + checkNoDialog(tester); checkRequest(message.id, '✔ zulip'); }); @@ -717,7 +717,7 @@ void main() { await tester.tap(findButtonForLabel('Mark as unresolved')); await tester.pumpAndSettle(); - checkNoErrorDialog(tester); + checkNoDialog(tester); checkRequest(message.id, 'zulip'); }); @@ -731,7 +731,7 @@ void main() { await tester.tap(findButtonForLabel('Mark as unresolved')); await tester.pumpAndSettle(); - checkNoErrorDialog(tester); + checkNoDialog(tester); checkRequest(message.id, 'zulip'); }); diff --git a/test/widgets/app_test.dart b/test/widgets/app_test.dart index 7b1388dbec..10aa7fe6d3 100644 --- a/test/widgets/app_test.dart +++ b/test/widgets/app_test.dart @@ -401,14 +401,14 @@ void main() { check(ZulipApp.ready).value.isFalse(); await tester.pump(); check(findSnackBarByText(message).evaluate()).isEmpty(); - checkNoErrorDialog(tester); + checkNoDialog(tester); check(ZulipApp.ready).value.isTrue(); // After app startup, reportErrorToUserBriefly displays a SnackBar. reportErrorToUserBriefly(message, details: details); await tester.pumpAndSettle(); check(findSnackBarByText(message).evaluate()).single; - checkNoErrorDialog(tester); + checkNoDialog(tester); // Open the error details dialog. await tester.tap(find.text('Details')); @@ -493,7 +493,7 @@ void main() { reportErrorToUserModally(title, message: message); check(ZulipApp.ready).value.isFalse(); await tester.pump(); - checkNoErrorDialog(tester); + checkNoDialog(tester); check(ZulipApp.ready).value.isTrue(); // After app startup, reportErrorToUserModally displays an [AlertDialog]. diff --git a/test/widgets/compose_box_test.dart b/test/widgets/compose_box_test.dart index e02fd97a15..b78243b9cf 100644 --- a/test/widgets/compose_box_test.dart +++ b/test/widgets/compose_box_test.dart @@ -276,7 +276,7 @@ void main() { await prepareWithContent(tester, makeStringWithCodePoints(kMaxMessageLengthCodePoints)); await tapSendButton(tester); - checkNoErrorDialog(tester); + checkNoDialog(tester); }); testWidgets('code points not counted unnecessarily', (tester) async { @@ -313,7 +313,7 @@ void main() { await prepareWithTopic(tester, makeStringWithCodePoints(kMaxTopicLengthCodePoints)); await tapSendButton(tester); - checkNoErrorDialog(tester); + checkNoDialog(tester); }); testWidgets('code points not counted unnecessarily', (tester) async { @@ -739,7 +739,7 @@ void main() { await setupAndTapSend(tester, prepareResponse: (int messageId) { connection.prepare(json: SendMessageResult(id: messageId).toJson()); }); - checkNoErrorDialog(tester); + checkNoDialog(tester); }); testWidgets('ZulipApiException', (tester) async { @@ -877,7 +877,7 @@ void main() { check(call.allowMultiple).equals(true); check(call.type).equals(FileType.media); - checkNoErrorDialog(tester); + checkNoDialog(tester); check(controller!.content.text) .equals('see image: [Uploading image.jpg…]()\n\n'); @@ -936,7 +936,7 @@ void main() { check(call.source).equals(ImageSource.camera); check(call.requestFullMetadata).equals(false); - checkNoErrorDialog(tester); + checkNoDialog(tester); check(controller!.content.text) .equals('see image: [Uploading image.jpg…]()\n\n'); diff --git a/test/widgets/dialog_checks.dart b/test/widgets/dialog_checks.dart index 86acec96ce..6d605e46fa 100644 --- a/test/widgets/dialog_checks.dart +++ b/test/widgets/dialog_checks.dart @@ -1,10 +1,15 @@ import 'package:checks/checks.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_checks/flutter_checks.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:url_launcher/url_launcher.dart'; import 'package:zulip/widgets/dialog.dart'; -/// In a widget test, check that showErrorDialog was called with the right text. +import '../model/binding.dart'; + +/// In a widget test, check that [showErrorDialog] was called with the right text. /// /// Checks for an error dialog matching an expected title /// and, optionally, matching an expected message. Fails if none is found. @@ -14,25 +19,55 @@ import 'package:zulip/widgets/dialog.dart'; Widget checkErrorDialog(WidgetTester tester, { required String expectedTitle, String? expectedMessage, + Uri? expectedLearnMoreButtonUrl, }) { - final dialog = tester.widget(find.byType(AlertDialog)); - tester.widget(find.descendant(matchRoot: true, - of: find.byWidget(dialog.title!), matching: find.text(expectedTitle))); - if (expectedMessage != null) { - tester.widget(find.descendant(matchRoot: true, - of: find.byWidget(dialog.content!), matching: find.text(expectedMessage))); - } + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + final dialog = tester.widget(find.bySubtype()); + tester.widget(find.descendant(matchRoot: true, + of: find.byWidget(dialog.title!), matching: find.text(expectedTitle))); + if (expectedMessage != null) { + tester.widget(find.descendant(matchRoot: true, + of: find.byWidget(dialog.content!), matching: find.text(expectedMessage))); + } + if (expectedLearnMoreButtonUrl != null) { + check(testBinding.takeLaunchUrlCalls()).single.equals(( + url: expectedLearnMoreButtonUrl, + mode: LaunchMode.inAppBrowserView)); + } + + return tester.widget(find.descendant(of: find.byWidget(dialog), + matching: find.widgetWithText(TextButton, 'OK'))); - // TODO check "Learn more" button? + case TargetPlatform.iOS: + case TargetPlatform.macOS: + final dialog = tester.widget(find.byType(CupertinoAlertDialog)); + tester.widget(find.descendant(matchRoot: true, + of: find.byWidget(dialog.title!), matching: find.text(expectedTitle))); + if (expectedMessage != null) { + tester.widget(find.descendant(matchRoot: true, + of: find.byWidget(dialog.content!), matching: find.text(expectedMessage))); + } + if (expectedLearnMoreButtonUrl != null) { + check(testBinding.takeLaunchUrlCalls()).single.equals(( + url: expectedLearnMoreButtonUrl, + mode: LaunchMode.externalApplication)); + } - return tester.widget( - find.descendant(of: find.byWidget(dialog), - matching: find.widgetWithText(TextButton, 'OK'))); + return tester.widget(find.descendant(of: find.byWidget(dialog), + matching: find.widgetWithText(CupertinoDialogAction, 'OK'))); + } } -// TODO(#996) update this to check for per-platform flavors of alert dialog -void checkNoErrorDialog(WidgetTester tester) { - check(find.byType(AlertDialog)).findsNothing(); +/// Checks that there is no dialog. +/// Fails if one is found. +void checkNoDialog(WidgetTester tester) { + check(find.byType(Dialog)).findsNothing(); + check(find.bySubtype()).findsNothing(); + check(find.byType(CupertinoAlertDialog)).findsNothing(); } /// In a widget test, check that [showSuggestedActionDialog] was called @@ -49,19 +84,35 @@ void checkNoErrorDialog(WidgetTester tester) { required String expectedMessage, String? expectedActionButtonText, }) { - final dialog = tester.widget(find.byType(AlertDialog)); - tester.widget(find.descendant(matchRoot: true, - of: find.byWidget(dialog.title!), matching: find.text(expectedTitle))); - tester.widget(find.descendant(matchRoot: true, - of: find.byWidget(dialog.content!), matching: find.text(expectedMessage))); + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + final dialog = tester.widget(find.bySubtype()); + tester.widget(find.descendant(matchRoot: true, + of: find.byWidget(dialog.title!), matching: find.text(expectedTitle))); + 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(TextButton, expectedActionButtonText ?? 'Continue'))); + final actionButton = tester.widget(find.descendant(of: find.byWidget(dialog), + matching: find.widgetWithText(TextButton, expectedActionButtonText ?? 'Continue'))); + final cancelButton = tester.widget(find.descendant(of: find.byWidget(dialog), + matching: find.widgetWithText(TextButton, 'Cancel'))); + return (actionButton, cancelButton); - final cancelButton = tester.widget( - find.descendant(of: find.byWidget(dialog), - matching: find.widgetWithText(TextButton, 'Cancel'))); + case TargetPlatform.iOS: + case TargetPlatform.macOS: + final dialog = tester.widget(find.byType(CupertinoAlertDialog)); + tester.widget(find.descendant(matchRoot: true, + of: find.byWidget(dialog.title!), matching: find.text(expectedTitle))); + tester.widget(find.descendant(matchRoot: true, + of: find.byWidget(dialog.content!), matching: find.text(expectedMessage))); - return (actionButton, cancelButton); + final actionButton = tester.widget(find.descendant(of: find.byWidget(dialog), + matching: find.widgetWithText(CupertinoDialogAction, expectedActionButtonText ?? 'Continue'))); + final cancelButton = tester.widget(find.descendant(of: find.byWidget(dialog), + matching: find.widgetWithText(CupertinoDialogAction, 'Cancel'))); + return (actionButton, cancelButton); + } } diff --git a/test/widgets/dialog_test.dart b/test/widgets/dialog_test.dart index 4082e45424..fd7975aeaf 100644 --- a/test/widgets/dialog_test.dart +++ b/test/widgets/dialog_test.dart @@ -1,29 +1,113 @@ import 'package:checks/checks.dart'; -import 'package:flutter/widgets.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:url_launcher/url_launcher.dart'; import 'package:zulip/widgets/dialog.dart'; import '../model/binding.dart'; +import 'dialog_checks.dart'; import 'test_app.dart'; void main() { TestZulipBinding.ensureInitialized(); + late BuildContext context; + + const title = "Dialog Title"; + const message = "Dialog message."; + + Future prepare(WidgetTester tester) async { + addTearDown(testBinding.reset); + + await tester.pumpWidget(const TestZulipApp( + child: Scaffold(body: Placeholder()))); + await tester.pump(); + context = tester.element(find.byType(Placeholder)); + } + group('showErrorDialog', () { - testWidgets('tap "Learn more" button', (tester) async { - addTearDown(testBinding.reset); - await tester.pumpWidget(TestZulipApp()); + testWidgets('show error dialog', (tester) async { + await prepare(tester); + + showErrorDialog(context: context, title: title, message: message); await tester.pump(); - final element = tester.element(find.byType(Placeholder)); + checkErrorDialog(tester, expectedTitle: title, expectedMessage: message); + }, variant: const TargetPlatformVariant({TargetPlatform.android, TargetPlatform.iOS})); - showErrorDialog(context: element, title: 'hello', - learnMoreButtonUrl: Uri.parse('https://foo.example')); + testWidgets('user closes error dialog', (tester) async { + await prepare(tester); + + showErrorDialog(context: context, title: title, message: message); + await tester.pump(); + + final button = checkErrorDialog(tester, expectedTitle: title); + await tester.tap(find.byWidget(button)); + await tester.pump(); + checkNoDialog(tester); + }, variant: const TargetPlatformVariant({TargetPlatform.android, TargetPlatform.iOS})); + + testWidgets('tap "Learn more" button', (tester) async { + await prepare(tester); + + final learnMoreButtonUrl = Uri.parse('https://foo.example'); + showErrorDialog(context: context, title: title, learnMoreButtonUrl: learnMoreButtonUrl); await tester.pump(); await tester.tap(find.text('Learn more')); - check(testBinding.takeLaunchUrlCalls()).single.equals(( - url: Uri.parse('https://foo.example'), - mode: LaunchMode.inAppBrowserView)); - }); + + checkErrorDialog(tester, expectedTitle: title, + expectedLearnMoreButtonUrl: learnMoreButtonUrl); + }, variant: const TargetPlatformVariant({TargetPlatform.android, TargetPlatform.iOS})); + }); + + group('showSuggestedActionDialog', () { + const actionButtonText = "Action"; + + testWidgets('show suggested action dialog', (tester) async { + await prepare(tester); + + showSuggestedActionDialog(context: context, title: title, message: message, + actionButtonText: actionButtonText, onActionButtonPress: () {}); + await tester.pump(); + + checkSuggestedActionDialog(tester, expectedTitle: title, expectedMessage: message, + expectedActionButtonText: actionButtonText); + }, variant: const TargetPlatformVariant({TargetPlatform.android, TargetPlatform.iOS})); + + testWidgets('user presses action button', (tester) async { + await prepare(tester); + + bool wasPressed = false; + void onActionButtonPress() { + wasPressed = true; + } + showSuggestedActionDialog(context: context, title: title, message: message, + actionButtonText: actionButtonText, onActionButtonPress: onActionButtonPress); + await tester.pump(); + + final (actionButton, _) = checkSuggestedActionDialog(tester, expectedTitle: title, + expectedMessage: message, expectedActionButtonText: actionButtonText); + await tester.tap(find.byWidget(actionButton)); + await tester.pump(); + checkNoDialog(tester); + check(wasPressed).isTrue(); + }, variant: const TargetPlatformVariant({TargetPlatform.android, TargetPlatform.iOS})); + + testWidgets('user cancels', (tester) async { + await prepare(tester); + + bool wasPressed = false; + void onActionButtonPress() { + wasPressed = true; + } + showSuggestedActionDialog(context: context, title: title, message: message, + actionButtonText: actionButtonText, onActionButtonPress: onActionButtonPress); + await tester.pump(); + + final (_, cancelButton) = checkSuggestedActionDialog(tester, expectedTitle: title, + expectedMessage: message, expectedActionButtonText: actionButtonText); + await tester.tap(find.byWidget(cancelButton)); + await tester.pump(); + checkNoDialog(tester); + check(wasPressed).isFalse(); + }, variant: const TargetPlatformVariant({TargetPlatform.android, TargetPlatform.iOS})); }); }