Skip to content

Commit 66c944f

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: #720 Signed-off-by: Zixuan James Li <[email protected]>
1 parent 378107c commit 66c944f

File tree

2 files changed

+73
-21
lines changed

2 files changed

+73
-21
lines changed

lib/widgets/compose_box.dart

+36-12
Original file line numberDiff line numberDiff line change
@@ -1006,6 +1006,7 @@ class _SendButtonState extends State<_SendButton> {
10061006
.sendMessage(destination: widget.getDestination(), content: content)
10071007
.timeout(kSendMessageTimeout);
10081008
widget.controller.content.clear();
1009+
widget.controller._sendMessageError.value = null;
10091010
} catch (e) {
10101011
if (!mounted) return;
10111012
final zulipLocalizations = ZulipLocalizations.of(context);
@@ -1016,9 +1017,7 @@ class _SendButtonState extends State<_SendButton> {
10161017
case TimeoutException(): message = zulipLocalizations.errorSendMessageTimeout;
10171018
default: rethrow;
10181019
}
1019-
showErrorDialog(context: context,
1020-
title: zulipLocalizations.errorMessageNotSent,
1021-
message: message);
1020+
widget.controller._sendMessageError.value = message;
10221021
return;
10231022
} finally {
10241023
widget.controller._enabled.value = true;
@@ -1246,11 +1245,15 @@ sealed class ComposeBoxController {
12461245
bool get enabled => _enabled.value;
12471246
final ValueNotifier<bool> _enabled = ValueNotifier<bool>(true);
12481247

1248+
String? get sendMessageError => _sendMessageError.value;
1249+
final ValueNotifier<String?> _sendMessageError = ValueNotifier<String?>(null);
1250+
12491251
@mustCallSuper
12501252
void dispose() {
12511253
content.dispose();
12521254
contentFocusNode.dispose();
12531255
_enabled.dispose();
1256+
_sendMessageError.dispose();
12541257
}
12551258
}
12561259

@@ -1269,13 +1272,15 @@ class StreamComposeBoxController extends ComposeBoxController {
12691272
class FixedDestinationComposeBoxController extends ComposeBoxController {}
12701273

12711274
class _ErrorBanner extends StatelessWidget {
1272-
const _ErrorBanner({required this.label});
1275+
const _ErrorBanner({required this.label, this.onDismiss});
12731276

12741277
final String label;
1278+
final void Function()? onDismiss;
12751279

12761280
@override
12771281
Widget build(BuildContext context) {
12781282
final designVariables = DesignVariables.of(context);
1283+
final iconButtonTheme = IconButtonTheme.of(context);
12791284
final labelTextStyle = TextStyle(
12801285
fontSize: 17,
12811286
height: 22 / 17,
@@ -1297,8 +1302,16 @@ class _ErrorBanner extends StatelessWidget {
12971302
child: Text(style: labelTextStyle,
12981303
label))),
12991304
const SizedBox(width: 8),
1300-
// TODO(#720) "x" button goes here.
1301-
// 24px square with 8px touchable padding in all directions?
1305+
if (onDismiss != null)
1306+
IconButton(
1307+
icon: Icon(
1308+
ZulipIcons.remove, color: designVariables.btnLabelAttLowIntDanger),
1309+
style: iconButtonTheme.style!.copyWith(
1310+
overlayColor: const WidgetStatePropertyAll(Colors.transparent),
1311+
splashFactory: NoSplash.splashFactory,
1312+
shape: const WidgetStatePropertyAll(ContinuousRectangleBorder(
1313+
borderRadius: BorderRadius.all(Radius.circular(4))))),
1314+
onPressed: onDismiss),
13021315
])));
13031316
}
13041317
}
@@ -1385,6 +1398,21 @@ class _ComposeBoxState extends State<ComposeBox> implements ComposeBoxState {
13851398
return null;
13861399
}
13871400

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

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);
1437+
return _ComposeBoxContainer(
1438+
body: body, errorBanner: _sendMessageErrorErrorBanner(context));
14151439
}
14161440
}

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)