diff --git a/assets/icons/ZulipIcons.ttf b/assets/icons/ZulipIcons.ttf index 4f54b9a730..94357d3ec8 100644 Binary files a/assets/icons/ZulipIcons.ttf and b/assets/icons/ZulipIcons.ttf differ diff --git a/assets/icons/remove.svg b/assets/icons/remove.svg new file mode 100644 index 0000000000..dcb1763c46 --- /dev/null +++ b/assets/icons/remove.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index 1b8e6ff8b8..a17b08e89e 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -169,6 +169,10 @@ "@errorQuotationFailed": { "description": "Error message when quoting a message failed." }, + "errorSendMessageTimeout": "Your message was not sent. Check your connection.", + "@errorSendMessageTimeout": { + "description": "Error message when failed to send a message due to timeout." + }, "errorServerMessage": "The server said:\n\n{message}", "@errorServerMessage": { "description": "Error message that quotes an error from the server.", diff --git a/lib/generated/l10n/zulip_localizations.dart b/lib/generated/l10n/zulip_localizations.dart index b058af8f2b..0edb09f37c 100644 --- a/lib/generated/l10n/zulip_localizations.dart +++ b/lib/generated/l10n/zulip_localizations.dart @@ -325,6 +325,12 @@ abstract class ZulipLocalizations { /// **'Quotation failed'** String get errorQuotationFailed; + /// Error message when failed to send a message due to timeout. + /// + /// In en, this message translates to: + /// **'Your message was not sent. Check your connection.'** + String get errorSendMessageTimeout; + /// Error message that quotes an error from the server. /// /// 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 95ff1d0aea..2a30e992dc 100644 --- a/lib/generated/l10n/zulip_localizations_ar.dart +++ b/lib/generated/l10n/zulip_localizations_ar.dart @@ -144,6 +144,9 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get errorQuotationFailed => 'Quotation failed'; + @override + String get errorSendMessageTimeout => 'Your message was not sent. Check your connection.'; + @override String errorServerMessage(String message) { return 'The server said:\n\n$message'; diff --git a/lib/generated/l10n/zulip_localizations_en.dart b/lib/generated/l10n/zulip_localizations_en.dart index d440ed2b10..06a1582d7c 100644 --- a/lib/generated/l10n/zulip_localizations_en.dart +++ b/lib/generated/l10n/zulip_localizations_en.dart @@ -144,6 +144,9 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get errorQuotationFailed => 'Quotation failed'; + @override + String get errorSendMessageTimeout => 'Your message was not sent. Check your connection.'; + @override String errorServerMessage(String message) { return 'The server said:\n\n$message'; diff --git a/lib/generated/l10n/zulip_localizations_ja.dart b/lib/generated/l10n/zulip_localizations_ja.dart index 42128ba024..6b2a6cd76e 100644 --- a/lib/generated/l10n/zulip_localizations_ja.dart +++ b/lib/generated/l10n/zulip_localizations_ja.dart @@ -144,6 +144,9 @@ class ZulipLocalizationsJa extends ZulipLocalizations { @override String get errorQuotationFailed => 'Quotation failed'; + @override + String get errorSendMessageTimeout => 'Your message was not sent. Check your connection.'; + @override String errorServerMessage(String message) { return 'The server said:\n\n$message'; diff --git a/lib/widgets/compose_box.dart b/lib/widgets/compose_box.dart index 114e392bc7..2cab3002ed 100644 --- a/lib/widgets/compose_box.dart +++ b/lib/widgets/compose_box.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:math'; import 'package:app_settings/app_settings.dart'; @@ -22,6 +23,8 @@ import 'store.dart'; import 'text.dart'; import 'theme.dart'; +const Duration kSendMessageTimeout = Duration(seconds: 5); + const double _composeButtonSize = 44; /// A [TextEditingController] for use in the compose box. @@ -295,6 +298,7 @@ class _ContentInputState extends State<_ContentInput> with WidgetsBindingObserve super.initState(); widget.controller.content.addListener(_contentChanged); widget.controller.contentFocusNode.addListener(_focusChanged); + widget.controller._enabled.addListener(_enabledChanged); WidgetsBinding.instance.addObserver(this); } @@ -306,6 +310,8 @@ class _ContentInputState extends State<_ContentInput> with WidgetsBindingObserve widget.controller.content.addListener(_contentChanged); oldWidget.controller.contentFocusNode.removeListener(_focusChanged); widget.controller.contentFocusNode.addListener(_focusChanged); + oldWidget.controller._enabled.removeListener(_enabledChanged); + widget.controller._enabled.addListener(_enabledChanged); } } @@ -313,6 +319,7 @@ class _ContentInputState extends State<_ContentInput> with WidgetsBindingObserve void dispose() { widget.controller.content.removeListener(_contentChanged); widget.controller.contentFocusNode.removeListener(_focusChanged); + widget.controller._enabled.removeListener(_enabledChanged); WidgetsBinding.instance.removeObserver(this); super.dispose(); } @@ -334,6 +341,12 @@ class _ContentInputState extends State<_ContentInput> with WidgetsBindingObserve store.typingNotifier.stoppedComposing(); } + void _enabledChanged() { + setState(() { + // The actual state lives in `widget.controller._enabled`. + }); + } + @override void didChangeAppLifecycleState(AppLifecycleState state) { switch (state) { @@ -395,44 +408,47 @@ class _ContentInputState extends State<_ContentInput> with WidgetsBindingObserve narrow: widget.narrow, controller: widget.controller.content, focusNode: widget.controller.contentFocusNode, - fieldViewBuilder: (context) => ConstrainedBox( - constraints: BoxConstraints(maxHeight: maxHeight(context)), - // This [ClipRect] replaces the [TextField] clipping we disable below. - child: ClipRect( - child: InsetShadowBox( - top: _verticalPadding, bottom: _verticalPadding, - color: designVariables.composeBoxBg, - child: TextField( - controller: widget.controller.content, - focusNode: widget.controller.contentFocusNode, - // Let the content show through the `contentPadding` so that - // our [InsetShadowBox] can fade it smoothly there. - clipBehavior: Clip.none, - style: TextStyle( - fontSize: _fontSize, - height: _lineHeightRatio, - color: designVariables.textInput), - // From the spec at - // https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=3960-5147&node-type=text&m=dev - // > Compose box has the height to fit 2 lines. This is [done] to - // > have a bigger hit area for the user to start the input. […] - minLines: 2, - maxLines: null, - textCapitalization: TextCapitalization.sentences, - decoration: InputDecoration( - // This padding ensures that the user can always scroll long - // content entirely out of the top or bottom shadow if desired. - // With this and the `minLines: 2` above, an empty content input - // gets 60px vertical distance (with no text-size scaling) - // between the top of the top shadow and the bottom of the - // bottom shadow. That's a bit more than the 54px given in the - // Figma, and we can revisit if needed, but it's tricky to get - // that 54px distance while also making the scrolling work like - // this and offering two lines of touchable area. - contentPadding: const EdgeInsets.symmetric(vertical: _verticalPadding), - hintText: widget.hintText, - hintStyle: TextStyle( - color: designVariables.textInput.withFadedAlpha(0.5)))))))); + fieldViewBuilder: (context) => Opacity( + opacity: widget.controller.enabled ? 1 : 0.5, + child: ConstrainedBox( + constraints: BoxConstraints(maxHeight: maxHeight(context)), + // This [ClipRect] replaces the [TextField] clipping we disable below. + child: ClipRect( + child: InsetShadowBox( + top: _verticalPadding, bottom: _verticalPadding, + color: designVariables.composeBoxBg, + child: TextField( + readOnly: !widget.controller.enabled, + controller: widget.controller.content, + focusNode: widget.controller.contentFocusNode, + // Let the content show through the `contentPadding` so that + // our [InsetShadowBox] can fade it smoothly there. + clipBehavior: Clip.none, + style: TextStyle( + fontSize: _fontSize, + height: _lineHeightRatio, + color: designVariables.textInput), + // From the spec at + // https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=3960-5147&node-type=text&m=dev + // > Compose box has the height to fit 2 lines. This is [done] to + // > have a bigger hit area for the user to start the input. […] + minLines: 2, + maxLines: null, + textCapitalization: TextCapitalization.sentences, + decoration: InputDecoration( + // This padding ensures that the user can always scroll long + // content entirely out of the top or bottom shadow if desired. + // With this and the `minLines: 2` above, an empty content input + // gets 60px vertical distance (with no text-size scaling) + // between the top of the top shadow and the bottom of the + // bottom shadow. That's a bit more than the 54px given in the + // Figma, and we can revisit if needed, but it's tricky to get + // that 54px distance while also making the scrolling work like + // this and offering two lines of touchable area. + contentPadding: const EdgeInsets.symmetric(vertical: _verticalPadding), + hintText: widget.hintText, + hintStyle: TextStyle( + color: designVariables.textInput.withFadedAlpha(0.5))))))))); } } @@ -513,20 +529,28 @@ class _TopicInput extends StatelessWidget { controller: controller.topic, focusNode: controller.topicFocusNode, contentFocusNode: controller.contentFocusNode, - fieldViewBuilder: (context) => Container( - padding: const EdgeInsets.only(top: 10, bottom: 9), - decoration: BoxDecoration(border: Border(bottom: BorderSide( - width: 1, - color: designVariables.foreground.withFadedAlpha(0.2)))), - child: TextField( - controller: controller.topic, - focusNode: controller.topicFocusNode, - textInputAction: TextInputAction.next, - style: topicTextStyle, - decoration: InputDecoration( - hintText: zulipLocalizations.composeBoxTopicHintText, - hintStyle: topicTextStyle.copyWith( - color: designVariables.textInput.withFadedAlpha(0.5)))))); + fieldViewBuilder: (context) => ValueListenableBuilder( + valueListenable: controller._enabled, + builder: (context, enabled, child) { + return Opacity( + opacity: enabled ? 1 : 0.5, + child: Container( + padding: const EdgeInsets.only(top: 10, bottom: 9), + decoration: BoxDecoration(border: Border(bottom: BorderSide( + width: 1, + color: designVariables.foreground.withFadedAlpha(0.2)))), + child: TextField( + readOnly: !enabled, + controller: controller.topic, + focusNode: controller.topicFocusNode, + textInputAction: TextInputAction.next, + style: topicTextStyle, + decoration: InputDecoration( + hintText: zulipLocalizations.composeBoxTopicHintText, + hintStyle: topicTextStyle.copyWith( + color: designVariables.textInput.withFadedAlpha(0.5)))))); + } + )); } } @@ -699,10 +723,14 @@ abstract class _AttachUploadsButton extends StatelessWidget { final zulipLocalizations = ZulipLocalizations.of(context); return SizedBox( width: _composeButtonSize, - child: IconButton( - icon: Icon(icon, color: designVariables.foreground.withFadedAlpha(0.5)), - tooltip: tooltip(zulipLocalizations), - onPressed: () => _handlePress(context))); + child: ValueListenableBuilder( + valueListenable: controller._enabled, + builder: (context, enabled, child) { + return IconButton( + icon: Icon(icon, color: designVariables.foreground.withFadedAlpha(0.5)), + tooltip: tooltip(zulipLocalizations), + onPressed: enabled ? () => _handlePress(context) : null); + })); } } @@ -882,6 +910,12 @@ class _SendButtonState extends State<_SendButton> { }); } + void _hasEnabledChanged() { + setState(() { + // The actual state lives in `widget.controller._enabled`. + }); + } + @override void initState() { super.initState(); @@ -890,6 +924,7 @@ class _SendButtonState extends State<_SendButton> { controller.topic.hasValidationErrors.addListener(_hasErrorsChanged); } controller.content.hasValidationErrors.addListener(_hasErrorsChanged); + controller._enabled.addListener(_hasEnabledChanged); } @override @@ -908,6 +943,8 @@ class _SendButtonState extends State<_SendButton> { } oldController.content.hasValidationErrors.removeListener(_hasErrorsChanged); controller.content.hasValidationErrors.addListener(_hasErrorsChanged); + oldController._enabled.removeListener(_hasEnabledChanged); + controller._enabled.addListener(_hasEnabledChanged); } @override @@ -917,6 +954,7 @@ class _SendButtonState extends State<_SendButton> { controller.topic.hasValidationErrors.removeListener(_hasErrorsChanged); } controller.content.hasValidationErrors.removeListener(_hasErrorsChanged); + controller._enabled.removeListener(_hasEnabledChanged); super.dispose(); } @@ -950,31 +988,39 @@ class _SendButtonState extends State<_SendButton> { return; } + if (!widget.controller.enabled) { + // On an accidental double-tap, the second tap shouldn't trigger this + // handler; we disable the button on the first tap. + assert(false); + return; + } + final store = PerAccountStoreWidget.of(context); final content = controller.content.textNormalized; - controller.content.clear(); - // The following `stoppedComposing` call is currently redundant, - // because clearing input sends a "typing stopped" notice. - // It will be necessary once we resolve #720. store.typingNotifier.stoppedComposing(); + widget.controller._enabled.value = false; try { - // TODO(#720) clear content input only on success response; - // while waiting, put input(s) and send button into a disabled - // "working on it" state (letting input text be selected for copying). - await store.sendMessage(destination: widget.getDestination(), content: content); - } on ApiRequestException catch (e) { + await store + .sendMessage(destination: widget.getDestination(), content: content) + .timeout(kSendMessageTimeout); + widget.controller.content.clear(); + widget.controller._sendMessageError.value = null; + } catch (e) { if (!mounted) return; final zulipLocalizations = ZulipLocalizations.of(context); - final message = switch (e) { - ZulipApiException() => zulipLocalizations.errorServerMessage(e.message), - _ => e.message, - }; - showErrorDialog(context: context, - title: zulipLocalizations.errorMessageNotSent, - message: message); + String message; + switch (e) { + case ZulipApiException(): message = zulipLocalizations.errorServerMessage(e.message); + case ApiRequestException(): message = e.message; + case TimeoutException(): message = zulipLocalizations.errorSendMessageTimeout; + default: rethrow; + } + widget.controller._sendMessageError.value = message; return; + } finally { + widget.controller._enabled.value = true; } } @@ -983,7 +1029,7 @@ class _SendButtonState extends State<_SendButton> { final designVariables = DesignVariables.of(context); final zulipLocalizations = ZulipLocalizations.of(context); - final iconColor = _hasValidationErrors + final iconColor = _hasValidationErrors || !widget.controller.enabled ? designVariables.icon.withFadedAlpha(0.5) : designVariables.icon; @@ -997,7 +1043,7 @@ class _SendButtonState extends State<_SendButton> { // ambient [ButtonStyle.overlayColor], where we set the color for // the highlight state to match the Figma design. color: iconColor), - onPressed: _send)); + onPressed: (widget.controller.enabled) ? _send : null)); } } @@ -1103,6 +1149,16 @@ abstract class _ComposeBoxBody extends StatelessWidget { final topicInput = buildTopicInput(); return Column(children: [ + ValueListenableBuilder( + valueListenable: controller._enabled, + builder: (context, enabled, child) { + return (!enabled) ? child! : const SizedBox.shrink(); + }, + child: LinearProgressIndicator( + minHeight: 2.0, + backgroundColor: designVariables.foreground.withFadedAlpha(0.2), + color: designVariables.foreground.withFadedAlpha(0.5), + )), Padding( padding: const EdgeInsets.symmetric(horizontal: 8), child: Theme( @@ -1118,7 +1174,12 @@ abstract class _ComposeBoxBody extends StatelessWidget { child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Row(children: composeButtons), + ValueListenableBuilder( + valueListenable: controller._enabled, + builder: (context, enabled, child) { + return Opacity(opacity: enabled ? 1 : 0.5, child: child); + }, + child: Row(children: composeButtons)), buildSendButton(), ]))), ]); @@ -1181,10 +1242,18 @@ sealed class ComposeBoxController { final content = ComposeContentController(); final contentFocusNode = FocusNode(); + bool get enabled => _enabled.value; + final ValueNotifier _enabled = ValueNotifier(true); + + String? get sendMessageError => _sendMessageError.value; + final ValueNotifier _sendMessageError = ValueNotifier(null); + @mustCallSuper void dispose() { content.dispose(); contentFocusNode.dispose(); + _enabled.dispose(); + _sendMessageError.dispose(); } } @@ -1203,13 +1272,15 @@ class StreamComposeBoxController extends ComposeBoxController { class FixedDestinationComposeBoxController extends ComposeBoxController {} class _ErrorBanner extends StatelessWidget { - const _ErrorBanner({required this.label}); + const _ErrorBanner({required this.label, this.onDismiss}); final String label; + final void Function()? onDismiss; @override Widget build(BuildContext context) { final designVariables = DesignVariables.of(context); + final iconButtonTheme = IconButtonTheme.of(context); final labelTextStyle = TextStyle( fontSize: 17, height: 22 / 17, @@ -1231,8 +1302,16 @@ class _ErrorBanner extends StatelessWidget { child: Text(style: labelTextStyle, label))), const SizedBox(width: 8), - // TODO(#720) "x" button goes here. - // 24px square with 8px touchable padding in all directions? + if (onDismiss != null) + IconButton( + icon: Icon( + ZulipIcons.remove, color: designVariables.btnLabelAttLowIntDanger), + style: iconButtonTheme.style!.copyWith( + overlayColor: const WidgetStatePropertyAll(Colors.transparent), + splashFactory: NoSplash.splashFactory, + shape: const WidgetStatePropertyAll(ContinuousRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(4))))), + onPressed: onDismiss), ]))); } } @@ -1292,7 +1371,7 @@ class _ComposeBoxState extends State implements ComposeBoxState { super.dispose(); } - Widget? _errorBanner(BuildContext context) { + Widget? _canPostInNarrowErrorBanner(BuildContext context) { final store = PerAccountStoreWidget.of(context); final selfUser = store.users[store.selfUserId]!; switch (widget.narrow) { @@ -1319,13 +1398,28 @@ class _ComposeBoxState extends State implements ComposeBoxState { return null; } + Widget _sendMessageErrorErrorBanner(BuildContext context) { + final designVariables = DesignVariables.of(context); + return ValueListenableBuilder( + valueListenable: controller._sendMessageError, + builder: (context, sendMessageError, child) { + if (sendMessageError == null) return const SizedBox.shrink(); + return _ErrorBanner( + label: sendMessageError, + onDismiss: () => controller._sendMessageError.value = null); + }, + child: IconButton(icon: Icon(ZulipIcons.remove, + color: designVariables.btnLabelAttLowIntDanger), + onPressed: () => controller._sendMessageError.value = null)); + } + @override Widget build(BuildContext context) { final Widget? body; - final errorBanner = _errorBanner(context); - if (errorBanner != null) { - return _ComposeBoxContainer(body: null, errorBanner: errorBanner); + final canPostInNarrowErrorBanner = _canPostInNarrowErrorBanner(context); + if (canPostInNarrowErrorBanner != null) { + return _ComposeBoxContainer(body: null, errorBanner: canPostInNarrowErrorBanner); } final narrow = widget.narrow; @@ -1340,11 +1434,7 @@ class _ComposeBoxState extends State implements ComposeBoxState { } } - // TODO(#720) dismissable message-send error, maybe something like: - // if (controller.sendMessageError.value != null) { - // errorBanner = _ErrorBanner(label: - // ZulipLocalizations.of(context).errorSendMessageTimeout); - // } - return _ComposeBoxContainer(body: body, errorBanner: null); + return _ComposeBoxContainer( + body: body, errorBanner: _sendMessageErrorErrorBanner(context)); } } diff --git a/lib/widgets/icons.dart b/lib/widgets/icons.dart index ea83562085..d21dedfaf8 100644 --- a/lib/widgets/icons.dart +++ b/lib/widgets/icons.dart @@ -93,32 +93,35 @@ abstract final class ZulipIcons { /// The Zulip custom icon "read_receipts". static const IconData read_receipts = IconData(0xf117, fontFamily: "Zulip Icons"); + /// The Zulip custom icon "remove". + static const IconData remove = IconData(0xf118, fontFamily: "Zulip Icons"); + /// The Zulip custom icon "send". - static const IconData send = IconData(0xf118, fontFamily: "Zulip Icons"); + static const IconData send = IconData(0xf119, fontFamily: "Zulip Icons"); /// The Zulip custom icon "share". - static const IconData share = IconData(0xf119, fontFamily: "Zulip Icons"); + static const IconData share = IconData(0xf11a, fontFamily: "Zulip Icons"); /// The Zulip custom icon "share_ios". - static const IconData share_ios = IconData(0xf11a, fontFamily: "Zulip Icons"); + static const IconData share_ios = IconData(0xf11b, fontFamily: "Zulip Icons"); /// The Zulip custom icon "smile". - static const IconData smile = IconData(0xf11b, fontFamily: "Zulip Icons"); + static const IconData smile = IconData(0xf11c, fontFamily: "Zulip Icons"); /// The Zulip custom icon "star". - static const IconData star = IconData(0xf11c, fontFamily: "Zulip Icons"); + static const IconData star = IconData(0xf11d, fontFamily: "Zulip Icons"); /// The Zulip custom icon "star_filled". - static const IconData star_filled = IconData(0xf11d, fontFamily: "Zulip Icons"); + static const IconData star_filled = IconData(0xf11e, fontFamily: "Zulip Icons"); /// The Zulip custom icon "topic". - static const IconData topic = IconData(0xf11e, fontFamily: "Zulip Icons"); + static const IconData topic = IconData(0xf11f, fontFamily: "Zulip Icons"); /// The Zulip custom icon "unmute". - static const IconData unmute = IconData(0xf11f, fontFamily: "Zulip Icons"); + static const IconData unmute = IconData(0xf120, fontFamily: "Zulip Icons"); /// The Zulip custom icon "user". - static const IconData user = IconData(0xf120, fontFamily: "Zulip Icons"); + static const IconData user = IconData(0xf121, fontFamily: "Zulip Icons"); // END GENERATED ICON DATA } diff --git a/test/widgets/compose_box_test.dart b/test/widgets/compose_box_test.dart index 11f3afdec1..ea2f3850cf 100644 --- a/test/widgets/compose_box_test.dart +++ b/test/widgets/compose_box_test.dart @@ -30,7 +30,6 @@ import '../model/binding.dart'; import '../model/test_store.dart'; import '../model/typing_status_test.dart'; import '../stdlib_checks.dart'; -import 'dialog_checks.dart'; import 'test_app.dart'; void main() { @@ -410,7 +409,7 @@ void main() { await tester.tap(find.byTooltip(zulipLocalizations.composeBoxSendTooltip)); await tester.pump(Duration.zero); - check(connection.lastRequest).isA() + check(connection.takeRequests()).single.isA() ..method.equals('POST') ..url.path.equals('/api/v1/messages') ..bodyFields.deepEquals({ @@ -430,6 +429,106 @@ void main() { check(errorDialogs).isEmpty(); }); + testWidgets('disable compose box while pending; clear text when finished', (tester) async { + await setupAndTapSend(tester, prepareResponse: (int messageId) { + connection.prepare(json: SendMessageResult( + id: messageId).toJson(), delay: const Duration(seconds: 2)); + }); + check(controller!.enabled).isFalse(); + check(controller!.content.text).isNotEmpty(); + + await tester.tap(find.byIcon(ZulipIcons.send)); + await tester.pump(Duration.zero); + check(connection.takeRequests()).isEmpty(); + + await tester.tap(find.byIcon(ZulipIcons.attach_file)); + await tester.pump(Duration.zero); + check(testBinding.takePickFilesCalls()).isEmpty(); + + await tester.pump(const Duration(seconds: 2)); + check(controller!.enabled).isTrue(); + check(controller!.content.text).isEmpty(); + }); + + testWidgets('re-enable compose box even on failure; do not clear text', (tester) async { + await setupAndTapSend(tester, prepareResponse: (_) { + connection.prepare( + httpStatus: 400, + json: {'result': 'error', 'code': 'BAD_REQUEST'}, + delay: const Duration(seconds: 2)); + }); + check(controller!.enabled).isFalse(); + final oldText = controller!.content.text; + check(oldText).isNotEmpty(); + + await tester.pump(const Duration(seconds: 2)); + check(controller!.enabled).isTrue(); + check(controller!.content.text).equals(oldText); + }); + + testWidgets('show progress bar while pending', (tester) async { + await setupAndTapSend(tester, prepareResponse: (int messageId) { + connection.prepare(json: SendMessageResult( + id: messageId).toJson(), delay: const Duration(seconds: 2)); + }); + check(controller!.enabled).isFalse(); + check(find.byType(LinearProgressIndicator)).findsOne(); + + await tester.pump(const Duration(seconds: 2)); + check(controller!.enabled).isTrue(); + check(find.byType(LinearProgressIndicator)).findsNothing(); + }); + + testWidgets('dismiss validation error banner by tapping the remove icon', (tester) async { + await setupAndTapSend(tester, prepareResponse: (_) { + return connection.prepare(httpStatus: 400, + json: {'result': 'error', 'code': 'BAD_REQUEST', 'msg': 'error'}); + }); + check(find.byIcon(ZulipIcons.remove)).findsOne(); + + await tester.tap(find.byIcon(ZulipIcons.remove)); + await tester.pump(); + check(find.byIcon(ZulipIcons.remove)).findsNothing(); + }); + + testWidgets('dismiss error banner after a successful request', (tester) async { + await setupAndTapSend(tester, prepareResponse: (_) { + return connection.prepare(httpStatus: 400, + json: {'result': 'error', 'code': 'BAD_REQUEST', 'msg': 'error'}); + }); + check(find.byIcon(ZulipIcons.remove)).findsOne(); + + await tester.enterText(contentInputFinder, 'hello world'); + check(find.byIcon(ZulipIcons.remove)).findsOne(); + + connection.prepare( + json: SendMessageResult(id: 123).toJson(), + delay: const Duration(seconds: 2)); + await tester.tap(find.byIcon(ZulipIcons.send)); + await tester.pump(); + check(find.byIcon(ZulipIcons.remove)).findsOne(); + + await tester.pump(const Duration(seconds: 2)); + check(find.byIcon(ZulipIcons.remove)).findsNothing(); + }); + + testWidgets('fail after timeout', (tester) async { + const longDelay = Duration(hours: 1); + assert(longDelay > kSendMessageTimeout); + await setupAndTapSend(tester, prepareResponse: (_) { + connection.prepare( + httpStatus: 400, + json: {'result': 'error', 'code': 'BAD_REQUEST'}, + delay: longDelay); + }); + + await tester.pump(kSendMessageTimeout); + final zulipLocalizations = GlobalLocalizations.zulipLocalizations; + check(find.text(zulipLocalizations.errorSendMessageTimeout)).findsOne(); + + await tester.pump(longDelay); + }); + testWidgets('ZulipApiException', (tester) async { await setupAndTapSend(tester, prepareResponse: (message) { connection.prepare( @@ -441,11 +540,9 @@ void main() { }); }); final zulipLocalizations = GlobalLocalizations.zulipLocalizations; - await tester.tap(find.byWidget(checkErrorDialog(tester, - expectedTitle: zulipLocalizations.errorMessageNotSent, - expectedMessage: zulipLocalizations.errorServerMessage( - 'You do not have permission to initiate direct message conversations.'), - ))); + check(find.text(zulipLocalizations.errorServerMessage( + 'You do not have permission to initiate direct message conversations.'), + )).findsOne(); }); }); diff --git a/test/widgets/message_list_test.dart b/test/widgets/message_list_test.dart index 1bd7f798cf..736c9137c9 100644 --- a/test/widgets/message_list_test.dart +++ b/test/widgets/message_list_test.dart @@ -29,6 +29,7 @@ import '../api/fake_api.dart'; import '../example_data.dart' as eg; import '../model/binding.dart'; import '../model/content_test.dart'; +import '../model/message_list_test.dart'; import '../model/test_store.dart'; import '../flutter_checks.dart'; import '../stdlib_checks.dart'; @@ -680,10 +681,16 @@ void main() { ..decoration.isNotNull().hintText.equals('Message #${otherChannel.name} > new topic') ..controller.isNotNull().text.equals('Some text'); + connection.takeRequests(); connection.prepare(json: SendMessageResult(id: 1).toJson()); + // Prepare for the progress indicator that shifts the message list + // and triggers a message fetch as soon as we send the message. + connection.prepare(json: olderResult( + anchor: message.id, foundOldest: true, messages: []).toJson()); await tester.tap(find.byIcon(ZulipIcons.send)); await tester.pump(); - check(connection.lastRequest).isA() + final requests = connection.takeRequests(); + check(requests.first).isA() ..method.equals('POST') ..url.path.equals('/api/v1/messages') ..bodyFields.deepEquals({