Skip to content

Commit 175cae0

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 70dc5de commit 175cae0

File tree

2 files changed

+89
-19
lines changed

2 files changed

+89
-19
lines changed

lib/widgets/compose_box.dart

+52-10
Original file line numberDiff line numberDiff line change
@@ -276,16 +276,33 @@ class ComposeContentController extends ComposeController<ContentValidationError>
276276
}
277277

278278
class _TopBar extends StatelessWidget {
279-
const _TopBar({required this.showProgressIndicator});
279+
const _TopBar({required this.showProgressIndicator, required this.sendMessageError});
280280

281281
final bool showProgressIndicator;
282+
final ValueNotifier<String?> sendMessageError;
282283

283284
@override
284285
Widget build(BuildContext context) {
286+
final designVariables = DesignVariables.of(context);
287+
final iconButtonTheme = IconButtonTheme.of(context);
285288
// TODO: Figure out a way so that this does not shift the message list
286289
// when it gains more height.
287290
return Column(children: [
288291
if (showProgressIndicator) _progressIndicator(context),
292+
ValueListenableBuilder(
293+
valueListenable: sendMessageError,
294+
builder: (context, errorMessage, child) {
295+
if (errorMessage != null) {
296+
return _ErrorBanner(label: errorMessage, action: child);
297+
}
298+
return const SizedBox.shrink();
299+
},
300+
child: IconButton(
301+
style: iconButtonTheme.style!.copyWith(
302+
shape: const WidgetStatePropertyAll(ContinuousRectangleBorder(
303+
borderRadius: BorderRadius.all(Radius.circular(4))))),
304+
icon: Icon(ZulipIcons.remove, color: designVariables.btnLabelAttLowIntDanger),
305+
onPressed: () => sendMessageError.value = null)),
289306
]);
290307
}
291308
}
@@ -968,12 +985,14 @@ class _SendButton extends StatefulWidget {
968985
required this.enabled,
969986
required this.topicController,
970987
required this.contentController,
988+
required this.sendMessageError,
971989
required this.getDestination,
972990
});
973991

974992
final ValueNotifier<bool> enabled;
975993
final ComposeTopicController? topicController;
976994
final ComposeContentController contentController;
995+
final ValueNotifier<String?> sendMessageError;
977996
final MessageDestination Function() getDestination;
978997

979998
@override
@@ -1053,6 +1072,7 @@ class _SendButtonState extends State<_SendButton> {
10531072
.sendMessage(destination: widget.getDestination(), content: content)
10541073
.timeout(kSendMessageTimeout);
10551074
widget.contentController.clear();
1075+
widget.sendMessageError.value = null;
10561076
} catch (e) {
10571077
if (!mounted) return;
10581078

@@ -1064,9 +1084,7 @@ class _SendButtonState extends State<_SendButton> {
10641084
case TimeoutException(): message = zulipLocalizations.errorSendMessageTimeout;
10651085
default: rethrow;
10661086
}
1067-
showErrorDialog(context: context,
1068-
title: zulipLocalizations.errorMessageNotSent,
1069-
message: message);
1087+
widget.sendMessageError.value = message;
10701088
return;
10711089
} finally {
10721090
widget.enabled.value = true;
@@ -1197,6 +1215,7 @@ abstract class ComposeBoxController<T extends StatefulWidget> extends State<T> {
11971215
ComposeTopicController? get topicController;
11981216
ComposeContentController get contentController;
11991217
FocusNode get contentFocusNode;
1218+
ValueNotifier<String?> get sendMessageError;
12001219
}
12011220

12021221
/// A compose box for use in a channel narrow.
@@ -1229,11 +1248,15 @@ class _StreamComposeBoxState extends State<_StreamComposeBox> implements Compose
12291248
FocusNode get topicFocusNode => _topicFocusNode;
12301249
final _topicFocusNode = FocusNode();
12311250

1251+
@override ValueNotifier<String?> get sendMessageError => _sendMessageError;
1252+
final _sendMessageError = ValueNotifier<String?>(null);
1253+
12321254
@override
12331255
void dispose() {
12341256
_topicController.dispose();
12351257
_contentController.dispose();
12361258
_contentFocusNode.dispose();
1259+
_sendMessageError.dispose();
12371260
super.dispose();
12381261
}
12391262

@@ -1243,7 +1266,10 @@ class _StreamComposeBoxState extends State<_StreamComposeBox> implements Compose
12431266
valueListenable: _enabled,
12441267
builder: (context, enabled, child) {
12451268
return _ComposeBoxLayout(
1246-
topBar: _TopBar(showProgressIndicator: !enabled),
1269+
topBar: _TopBar(
1270+
showProgressIndicator: !enabled,
1271+
sendMessageError: sendMessageError,
1272+
),
12471273
topicInput: _TopicInput(
12481274
enabled: enabled,
12491275
streamId: widget.narrow.streamId,
@@ -1267,6 +1293,7 @@ class _StreamComposeBoxState extends State<_StreamComposeBox> implements Compose
12671293
enabled: _enabled,
12681294
topicController: _topicController,
12691295
contentController: _contentController,
1296+
sendMessageError: _sendMessageError,
12701297
getDestination: () => StreamDestination(
12711298
widget.narrow.streamId, _topicController.textNormalized),
12721299
));
@@ -1275,9 +1302,10 @@ class _StreamComposeBoxState extends State<_StreamComposeBox> implements Compose
12751302
}
12761303

12771304
class _ErrorBanner extends StatelessWidget {
1278-
const _ErrorBanner({required this.label});
1305+
const _ErrorBanner({required this.label, this.action});
12791306

12801307
final String label;
1308+
final Widget? action;
12811309

12821310
@override
12831311
Widget build(BuildContext context) {
@@ -1291,17 +1319,23 @@ class _ErrorBanner extends StatelessWidget {
12911319
// which is a variable equivalent to this value.
12921320
wght: 600));
12931321

1322+
final padding = (action == null)
1323+
// Ensure that the text is centered when it is the only element.
1324+
? const EdgeInsets.symmetric(horizontal: 16, vertical: 9)
1325+
: const EdgeInsetsDirectional.fromSTEB(16, 9, 8, 9);
1326+
12941327
return Container(
12951328
constraints: const BoxConstraints(minHeight: 40),
1296-
decoration: BoxDecoration(
1297-
color: designVariables.bannerBgIntDanger),
1329+
decoration: BoxDecoration(color: designVariables.bannerBgIntDanger),
12981330
child: Row(
12991331
mainAxisAlignment: MainAxisAlignment.spaceBetween,
1332+
crossAxisAlignment: CrossAxisAlignment.start,
13001333
children: [
13011334
Expanded(
13021335
child: Padding(
1303-
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 9),
1336+
padding: padding,
13041337
child: Text(label, style: labelTextStyle))),
1338+
if (action != null) action!,
13051339
]));
13061340
}
13071341
}
@@ -1327,10 +1361,14 @@ class _FixedDestinationComposeBoxState extends State<_FixedDestinationComposeBox
13271361
@override FocusNode get contentFocusNode => _contentFocusNode;
13281362
final _contentFocusNode = FocusNode();
13291363

1364+
@override ValueNotifier<String?> get sendMessageError => _sendMessageError;
1365+
final _sendMessageError = ValueNotifier<String?>(null);
1366+
13301367
@override
13311368
void dispose() {
13321369
_contentController.dispose();
13331370
_contentFocusNode.dispose();
1371+
_sendMessageError.dispose();
13341372
super.dispose();
13351373
}
13361374

@@ -1340,7 +1378,10 @@ class _FixedDestinationComposeBoxState extends State<_FixedDestinationComposeBox
13401378
valueListenable: _enabled,
13411379
builder: (context, enabled, child) {
13421380
return _ComposeBoxLayout(
1343-
topBar: _TopBar(showProgressIndicator: !enabled),
1381+
topBar: _TopBar(
1382+
showProgressIndicator: !enabled,
1383+
sendMessageError: sendMessageError,
1384+
),
13441385
topicInput: null,
13451386
contentInput: _FixedDestinationContentInput(
13461387
enabled: enabled,
@@ -1357,6 +1398,7 @@ class _FixedDestinationComposeBoxState extends State<_FixedDestinationComposeBox
13571398
enabled: _enabled,
13581399
topicController: null,
13591400
contentController: _contentController,
1401+
sendMessageError: _sendMessageError,
13601402
getDestination: () => widget.narrow.destination,
13611403
));
13621404
});

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() {
@@ -476,6 +475,39 @@ void main() {
476475
check(composeBoxController.contentController.text).equals(oldText);
477476
});
478477

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

489521
await tester.pump(kSendMessageTimeout);
490522
final zulipLocalizations = GlobalLocalizations.zulipLocalizations;
491-
await tester.tap(find.byWidget(checkErrorDialog(tester,
492-
expectedTitle: zulipLocalizations.errorMessageNotSent,
493-
expectedMessage: zulipLocalizations.errorSendMessageTimeout)));
523+
check(find.text(zulipLocalizations.errorSendMessageTimeout)).findsOne();
494524

495525
await tester.pump(longDelay);
496526
});
@@ -506,11 +536,9 @@ void main() {
506536
});
507537
});
508538
final zulipLocalizations = GlobalLocalizations.zulipLocalizations;
509-
await tester.tap(find.byWidget(checkErrorDialog(tester,
510-
expectedTitle: zulipLocalizations.errorMessageNotSent,
511-
expectedMessage: zulipLocalizations.errorServerMessage(
512-
'You do not have permission to initiate direct message conversations.'),
513-
)));
539+
check(find.text(zulipLocalizations.errorServerMessage(
540+
'You do not have permission to initiate direct message conversations.'),
541+
)).findsOne();
514542
});
515543
});
516544

0 commit comments

Comments
 (0)