Skip to content

Commit 6a878a4

Browse files
committed
compose: Show send message error with dismissable error banners
Different from the Figma, we use a sharp border instead of a rounded one, because most of the time (for error messages of a single line) the 40x40 button is attached to the top/bottom/right border, and a rounded border would leave some blank space at the corners. We also align the button to the top when we soft wrap the error message; centering it leaves some awkward vertical padding. Apart from that, when the action button is not available (e.g. _ErrorBanner that replaces the compose box in narrows without post permission), the padding around the label is adjusted to center it. Fixes: zulip#720 Signed-off-by: Zixuan James Li <[email protected]>
1 parent 378107c commit 6a878a4

File tree

2 files changed

+74
-21
lines changed

2 files changed

+74
-21
lines changed

lib/widgets/compose_box.dart

+37-12
Original file line numberDiff line numberDiff line change
@@ -1002,10 +1002,12 @@ class _SendButtonState extends State<_SendButton> {
10021002
widget.controller._enabled.value = false;
10031003

10041004
try {
1005+
throw TimeoutException('asd');
10051006
await store
10061007
.sendMessage(destination: widget.getDestination(), content: content)
10071008
.timeout(kSendMessageTimeout);
10081009
widget.controller.content.clear();
1010+
widget.controller._sendMessageError.value = null;
10091011
} catch (e) {
10101012
if (!mounted) return;
10111013
final zulipLocalizations = ZulipLocalizations.of(context);
@@ -1016,9 +1018,7 @@ class _SendButtonState extends State<_SendButton> {
10161018
case TimeoutException(): message = zulipLocalizations.errorSendMessageTimeout;
10171019
default: rethrow;
10181020
}
1019-
showErrorDialog(context: context,
1020-
title: zulipLocalizations.errorMessageNotSent,
1021-
message: message);
1021+
widget.controller._sendMessageError.value = message;
10221022
return;
10231023
} finally {
10241024
widget.controller._enabled.value = true;
@@ -1246,11 +1246,15 @@ sealed class ComposeBoxController {
12461246
bool get enabled => _enabled.value;
12471247
final ValueNotifier<bool> _enabled = ValueNotifier<bool>(true);
12481248

1249+
String? get sendMessageError => _sendMessageError.value;
1250+
final ValueNotifier<String?> _sendMessageError = ValueNotifier<String?>(null);
1251+
12491252
@mustCallSuper
12501253
void dispose() {
12511254
content.dispose();
12521255
contentFocusNode.dispose();
12531256
_enabled.dispose();
1257+
_sendMessageError.dispose();
12541258
}
12551259
}
12561260

@@ -1269,13 +1273,15 @@ class StreamComposeBoxController extends ComposeBoxController {
12691273
class FixedDestinationComposeBoxController extends ComposeBoxController {}
12701274

12711275
class _ErrorBanner extends StatelessWidget {
1272-
const _ErrorBanner({required this.label});
1276+
const _ErrorBanner({required this.label, this.onDismiss});
12731277

12741278
final String label;
1279+
final void Function()? onDismiss;
12751280

12761281
@override
12771282
Widget build(BuildContext context) {
12781283
final designVariables = DesignVariables.of(context);
1284+
final iconButtonTheme = IconButtonTheme.of(context);
12791285
final labelTextStyle = TextStyle(
12801286
fontSize: 17,
12811287
height: 22 / 17,
@@ -1297,8 +1303,16 @@ class _ErrorBanner extends StatelessWidget {
12971303
child: Text(style: labelTextStyle,
12981304
label))),
12991305
const SizedBox(width: 8),
1300-
// TODO(#720) "x" button goes here.
1301-
// 24px square with 8px touchable padding in all directions?
1306+
if (onDismiss != null)
1307+
IconButton(
1308+
icon: Icon(
1309+
ZulipIcons.remove, color: designVariables.btnLabelAttLowIntDanger),
1310+
style: iconButtonTheme.style!.copyWith(
1311+
overlayColor: const WidgetStatePropertyAll(Colors.transparent),
1312+
splashFactory: NoSplash.splashFactory,
1313+
shape: const WidgetStatePropertyAll(ContinuousRectangleBorder(
1314+
borderRadius: BorderRadius.all(Radius.circular(4))))),
1315+
onPressed: onDismiss),
13021316
])));
13031317
}
13041318
}
@@ -1385,6 +1399,21 @@ class _ComposeBoxState extends State<ComposeBox> implements ComposeBoxState {
13851399
return null;
13861400
}
13871401

1402+
Widget _sendMessageErrorErrorBanner(BuildContext context) {
1403+
final designVariables = DesignVariables.of(context);
1404+
return ValueListenableBuilder(
1405+
valueListenable: controller._sendMessageError,
1406+
builder: (context, sendMessageError, child) {
1407+
if (sendMessageError == null) return const SizedBox.shrink();
1408+
return _ErrorBanner(
1409+
label: sendMessageError,
1410+
onDismiss: () => controller._sendMessageError.value = null);
1411+
},
1412+
child: IconButton(icon: Icon(ZulipIcons.remove,
1413+
color: designVariables.btnLabelAttLowIntDanger),
1414+
onPressed: () => controller._sendMessageError.value = null));
1415+
}
1416+
13881417
@override
13891418
Widget build(BuildContext context) {
13901419
final Widget? body;
@@ -1406,11 +1435,7 @@ class _ComposeBoxState extends State<ComposeBox> implements ComposeBoxState {
14061435
}
14071436
}
14081437

1409-
// TODO(#720) dismissable message-send error, maybe something like:
1410-
// if (controller.sendMessageError.value != null) {
1411-
// errorBanner = _ErrorBanner(label:
1412-
// ZulipLocalizations.of(context).errorSendMessageTimeout);
1413-
// }
1414-
return _ComposeBoxContainer(body: body, errorBanner: null);
1438+
return _ComposeBoxContainer(
1439+
body: body, errorBanner: _sendMessageErrorErrorBanner(context));
14151440
}
14161441
}

test/widgets/compose_box_test.dart

+37-9
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,6 @@ import '../model/binding.dart';
3030
import '../model/test_store.dart';
3131
import '../model/typing_status_test.dart';
3232
import '../stdlib_checks.dart';
33-
import 'dialog_checks.dart';
3433
import 'test_app.dart';
3534

3635
void main() {
@@ -480,6 +479,39 @@ void main() {
480479
check(find.byType(LinearProgressIndicator)).findsNothing();
481480
});
482481

482+
testWidgets('dismiss validation error banner by tapping the remove icon', (tester) async {
483+
await setupAndTapSend(tester, prepareResponse: (_) {
484+
return connection.prepare(httpStatus: 400,
485+
json: {'result': 'error', 'code': 'BAD_REQUEST', 'msg': 'error'});
486+
});
487+
check(find.byIcon(ZulipIcons.remove)).findsOne();
488+
489+
await tester.tap(find.byIcon(ZulipIcons.remove));
490+
await tester.pump();
491+
check(find.byIcon(ZulipIcons.remove)).findsNothing();
492+
});
493+
494+
testWidgets('dismiss error banner after a successful request', (tester) async {
495+
await setupAndTapSend(tester, prepareResponse: (_) {
496+
return connection.prepare(httpStatus: 400,
497+
json: {'result': 'error', 'code': 'BAD_REQUEST', 'msg': 'error'});
498+
});
499+
check(find.byIcon(ZulipIcons.remove)).findsOne();
500+
501+
await tester.enterText(contentInputFinder, 'hello world');
502+
check(find.byIcon(ZulipIcons.remove)).findsOne();
503+
504+
connection.prepare(
505+
json: SendMessageResult(id: 123).toJson(),
506+
delay: const Duration(seconds: 2));
507+
await tester.tap(find.byIcon(ZulipIcons.send));
508+
await tester.pump();
509+
check(find.byIcon(ZulipIcons.remove)).findsOne();
510+
511+
await tester.pump(const Duration(seconds: 2));
512+
check(find.byIcon(ZulipIcons.remove)).findsNothing();
513+
});
514+
483515
testWidgets('fail after timeout', (tester) async {
484516
const longDelay = Duration(hours: 1);
485517
assert(longDelay > kSendMessageTimeout);
@@ -492,9 +524,7 @@ void main() {
492524

493525
await tester.pump(kSendMessageTimeout);
494526
final zulipLocalizations = GlobalLocalizations.zulipLocalizations;
495-
await tester.tap(find.byWidget(checkErrorDialog(tester,
496-
expectedTitle: zulipLocalizations.errorMessageNotSent,
497-
expectedMessage: zulipLocalizations.errorSendMessageTimeout)));
527+
check(find.text(zulipLocalizations.errorSendMessageTimeout)).findsOne();
498528

499529
await tester.pump(longDelay);
500530
});
@@ -510,11 +540,9 @@ void main() {
510540
});
511541
});
512542
final zulipLocalizations = GlobalLocalizations.zulipLocalizations;
513-
await tester.tap(find.byWidget(checkErrorDialog(tester,
514-
expectedTitle: zulipLocalizations.errorMessageNotSent,
515-
expectedMessage: zulipLocalizations.errorServerMessage(
516-
'You do not have permission to initiate direct message conversations.'),
517-
)));
543+
check(find.text(zulipLocalizations.errorServerMessage(
544+
'You do not have permission to initiate direct message conversations.'),
545+
)).findsOne();
518546
});
519547
});
520548

0 commit comments

Comments
 (0)