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({