Skip to content

Commit 96aff6a

Browse files
committed
compose: Show error with dismissable error banners
This replaces error dialogs. The banners are stackable. Signed-off-by: Zixuan James Li <[email protected]>
1 parent 0a9a310 commit 96aff6a

File tree

2 files changed

+131
-23
lines changed

2 files changed

+131
-23
lines changed

lib/widgets/compose_box.dart

Lines changed: 84 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -275,17 +275,65 @@ class ComposeContentController extends ComposeController<ContentValidationError>
275275
}
276276
}
277277

278-
class _TopBar extends StatelessWidget {
279-
const _TopBar({required this.showProgressIndicator});
278+
class SendErrorController extends ChangeNotifier {
279+
SendErrorController();
280+
281+
Iterable<String> get errors => _errors;
282+
Set<String> _errors = {};
283+
set errors (Iterable<String> value) {
284+
_errors = Set.from(value);
285+
assert(_errors.length == value.length, 'There should not be duplicate errors');
286+
notifyListeners();
287+
}
288+
289+
void remove(String value) {
290+
final removed = _errors.remove(value);
291+
assert(removed);
292+
notifyListeners();
293+
}
294+
}
295+
296+
class _TopBar extends StatefulWidget {
297+
const _TopBar({required this.showProgressIndicator, required this.sendErrorController});
280298

281299
final bool showProgressIndicator;
300+
final SendErrorController sendErrorController;
301+
302+
@override
303+
State<_TopBar> createState() => _TopBarState();
304+
}
305+
306+
class _TopBarState extends State<_TopBar> {
307+
@override
308+
void initState() {
309+
super.initState();
310+
widget.sendErrorController.addListener(_sendErrorChanged);
311+
}
312+
313+
@override
314+
void dispose() {
315+
widget.sendErrorController.removeListener(_sendErrorChanged);
316+
super.dispose();
317+
}
318+
319+
void _sendErrorChanged() {
320+
setState(() {
321+
// The actual state lives in `widget.sendErrorController`.
322+
});
323+
}
282324

283325
@override
284326
Widget build(BuildContext context) {
327+
final designVariables = DesignVariables.of(context);
285328
// TODO: Figure out a way so that this does not shift the message list
286329
// when it gains more height.
287330
return Column(children: [
288-
if (showProgressIndicator) _progressIndicator(context),
331+
if (widget.showProgressIndicator) _progressIndicator(context),
332+
for (final value in widget.sendErrorController.errors)
333+
_ErrorBanner(label: value,
334+
action: IconButton(icon: Icon(
335+
ZulipIcons.remove, color: designVariables.btnLabelAttLowIntDanger),
336+
onPressed: () => widget.sendErrorController.remove(value))),
289337
]);
290338
}
291339
}
@@ -968,12 +1016,14 @@ class _SendButton extends StatefulWidget {
9681016
required this.enabled,
9691017
required this.topicController,
9701018
required this.contentController,
1019+
required this.sendErrorController,
9711020
required this.getDestination,
9721021
});
9731022

9741023
final ValueNotifier<bool> enabled;
9751024
final ComposeTopicController? topicController;
9761025
final ComposeContentController contentController;
1026+
final SendErrorController sendErrorController;
9771027
final MessageDestination Function() getDestination;
9781028

9791029
@override
@@ -1029,10 +1079,7 @@ class _SendButtonState extends State<_SendButton> {
10291079
for (final error in widget.contentController.validationErrors)
10301080
error.message(zulipLocalizations),
10311081
];
1032-
showErrorDialog(
1033-
context: context,
1034-
title: zulipLocalizations.errorMessageNotSent,
1035-
message: validationErrorMessages.join('\n\n'));
1082+
widget.sendErrorController.errors = validationErrorMessages;
10361083
return;
10371084
}
10381085
if (!widget.enabled.value) {
@@ -1053,6 +1100,7 @@ class _SendButtonState extends State<_SendButton> {
10531100
.sendMessage(destination: widget.getDestination(), content: content)
10541101
.timeout(kSendMessageTimeout);
10551102
widget.contentController.clear();
1103+
widget.sendErrorController.errors = [];
10561104
} catch (e) {
10571105
if (!mounted) return;
10581106

@@ -1064,9 +1112,7 @@ class _SendButtonState extends State<_SendButton> {
10641112
case ApiRequestException(): message = e.message;
10651113
default: rethrow;
10661114
}
1067-
showErrorDialog(context: context,
1068-
title: zulipLocalizations.errorMessageNotSent,
1069-
message: message);
1115+
widget.sendErrorController.errors = [message];
10701116
return;
10711117
} finally {
10721118
widget.enabled.value = true;
@@ -1196,6 +1242,7 @@ abstract class ComposeBoxController<T extends StatefulWidget> extends State<T> {
11961242
bool get enabled;
11971243
ComposeTopicController? get topicController;
11981244
ComposeContentController get contentController;
1245+
SendErrorController get sendErrorController;
11991246
FocusNode get contentFocusNode;
12001247
}
12011248

@@ -1229,6 +1276,9 @@ class _StreamComposeBoxState extends State<_StreamComposeBox> implements Compose
12291276
FocusNode get topicFocusNode => _topicFocusNode;
12301277
final _topicFocusNode = FocusNode();
12311278

1279+
@override SendErrorController get sendErrorController => _sendErrorController;
1280+
final _sendErrorController = SendErrorController();
1281+
12321282
@override
12331283
void initState() {
12341284
super.initState();
@@ -1241,6 +1291,7 @@ class _StreamComposeBoxState extends State<_StreamComposeBox> implements Compose
12411291
_topicController.dispose();
12421292
_contentController.dispose();
12431293
_contentFocusNode.dispose();
1294+
_sendErrorController.dispose();
12441295
super.dispose();
12451296
}
12461297

@@ -1253,7 +1304,10 @@ class _StreamComposeBoxState extends State<_StreamComposeBox> implements Compose
12531304
@override
12541305
Widget build(BuildContext context) {
12551306
return _ComposeBoxLayout(
1256-
topBar: _TopBar(showProgressIndicator: !enabled),
1307+
topBar: _TopBar(
1308+
showProgressIndicator: !enabled,
1309+
sendErrorController: sendErrorController,
1310+
),
12571311
topicInput: _TopicInput(
12581312
enabled: enabled,
12591313
streamId: widget.narrow.streamId,
@@ -1277,16 +1331,18 @@ class _StreamComposeBoxState extends State<_StreamComposeBox> implements Compose
12771331
enabled: _enabled,
12781332
topicController: _topicController,
12791333
contentController: _contentController,
1334+
sendErrorController: _sendErrorController,
12801335
getDestination: () => StreamDestination(
12811336
widget.narrow.streamId, _topicController.textNormalized),
12821337
));
12831338
}
12841339
}
12851340

12861341
class _ErrorBanner extends StatelessWidget {
1287-
const _ErrorBanner({required this.label});
1342+
const _ErrorBanner({required this.label, this.action});
12881343

12891344
final String label;
1345+
final Widget? action;
12901346

12911347
@override
12921348
Widget build(BuildContext context) {
@@ -1298,6 +1354,11 @@ class _ErrorBanner extends StatelessWidget {
12981354
// which is a variable equivalent to this value.
12991355
wght: 600));
13001356

1357+
final padding = (action == null)
1358+
// Ensure that the text is centered when it is the only element.
1359+
? const EdgeInsets.symmetric(horizontal: 16, vertical: 5)
1360+
: const EdgeInsetsDirectional.fromSTEB(16, 5, 8, 5);
1361+
13011362
return Container(
13021363
constraints: const BoxConstraints(minHeight: 40),
13031364
decoration: BoxDecoration(
@@ -1307,8 +1368,9 @@ class _ErrorBanner extends StatelessWidget {
13071368
children: [
13081369
Expanded(
13091370
child: Padding(
1310-
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 5),
1371+
padding: padding,
13111372
child: Text(label, style: labelTextStyle))),
1373+
if (action != null) action!,
13121374
]));
13131375
}
13141376
}
@@ -1334,6 +1396,9 @@ class _FixedDestinationComposeBoxState extends State<_FixedDestinationComposeBox
13341396
@override FocusNode get contentFocusNode => _contentFocusNode;
13351397
final _contentFocusNode = FocusNode();
13361398

1399+
@override SendErrorController get sendErrorController => _sendErrorController;
1400+
final _sendErrorController = SendErrorController();
1401+
13371402
@override
13381403
void initState() {
13391404
super.initState();
@@ -1345,6 +1410,7 @@ class _FixedDestinationComposeBoxState extends State<_FixedDestinationComposeBox
13451410
_enabled.dispose();
13461411
_contentController.dispose();
13471412
_contentFocusNode.dispose();
1413+
_sendErrorController.dispose();
13481414
super.dispose();
13491415
}
13501416

@@ -1357,7 +1423,10 @@ class _FixedDestinationComposeBoxState extends State<_FixedDestinationComposeBox
13571423
@override
13581424
Widget build(BuildContext context) {
13591425
return _ComposeBoxLayout(
1360-
topBar: _TopBar(showProgressIndicator: !enabled),
1426+
topBar: _TopBar(
1427+
showProgressIndicator: !enabled,
1428+
sendErrorController: sendErrorController,
1429+
),
13611430
topicInput: null,
13621431
contentInput: _FixedDestinationContentInput(
13631432
enabled: enabled,
@@ -1374,6 +1443,7 @@ class _FixedDestinationComposeBoxState extends State<_FixedDestinationComposeBox
13741443
enabled: _enabled,
13751444
topicController: null,
13761445
contentController: _contentController,
1446+
sendErrorController: _sendErrorController,
13771447
getDestination: () => widget.narrow.destination,
13781448
));
13791449
}

test/widgets/compose_box_test.dart

Lines changed: 47 additions & 9 deletions
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() {
@@ -488,9 +487,7 @@ void main() {
488487

489488
await tester.pump(kSendMessageTimeout);
490489
final zulipLocalizations = GlobalLocalizations.zulipLocalizations;
491-
await tester.tap(find.byWidget(checkErrorDialog(tester,
492-
expectedTitle: zulipLocalizations.errorMessageNotSent,
493-
expectedMessage: zulipLocalizations.errorSendMessageTimeout)));
490+
check(find.text(zulipLocalizations.errorSendMessageTimeout)).findsOne();
494491

495492
await tester.pump(longDelay);
496493
});
@@ -506,11 +503,8 @@ void main() {
506503
});
507504
});
508505
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-
)));
506+
check(find.text(zulipLocalizations.errorServerMessage(
507+
'You do not have permission to initiate direct message conversations.'))).findsOne();
514508
});
515509
});
516510

@@ -860,6 +854,50 @@ void main() {
860854
checkComposeBox(isShown: true);
861855
});
862856
});
857+
858+
group('dismiss banners', () {
859+
testWidgets('dismiss validation error banner by clicking remove icon', (tester) async {
860+
final channel = eg.stream();
861+
final zulipLocalizations = GlobalLocalizations.zulipLocalizations;
862+
await prepareComposeBox(tester,
863+
narrow: TopicNarrow(channel.streamId, 'topic'), streams: [channel]);
864+
865+
await tester.tap(find.byIcon(ZulipIcons.send));
866+
await tester.pump();
867+
check(find.text(zulipLocalizations.contentValidationErrorEmpty)).findsOne();
868+
869+
await tester.tap(find.byIcon(ZulipIcons.remove));
870+
await tester.pump();
871+
check(find.text(zulipLocalizations.contentValidationErrorEmpty)).findsNothing();
872+
});
873+
874+
testWidgets('dismiss error banner after a successful request', (tester) async {
875+
TypingNotifier.debugEnable = false;
876+
addTearDown(TypingNotifier.debugReset);
877+
878+
final channel = eg.stream();
879+
final zulipLocalizations = GlobalLocalizations.zulipLocalizations;
880+
await prepareComposeBox(tester,
881+
narrow: TopicNarrow(channel.streamId, 'topic'), streams: [channel]);
882+
883+
await tester.tap(find.byIcon(ZulipIcons.send));
884+
await tester.pump();
885+
check(find.text(zulipLocalizations.contentValidationErrorEmpty)).findsOne();
886+
887+
connection.prepare(
888+
json: SendMessageResult(id: 123).toJson(),
889+
delay: const Duration(seconds: 2));
890+
await tester.enterText(contentInputFinder, 'hello world');
891+
check(find.text(zulipLocalizations.contentValidationErrorEmpty)).findsOne();
892+
893+
await tester.tap(find.byIcon(ZulipIcons.send));
894+
await tester.pump();
895+
check(find.text(zulipLocalizations.contentValidationErrorEmpty)).findsOne();
896+
897+
await tester.pump(const Duration(seconds: 2));
898+
check(find.text(zulipLocalizations.contentValidationErrorEmpty)).findsNothing();
899+
});
900+
});
863901
});
864902

865903
group('ComposeBox content input scaling', () {

0 commit comments

Comments
 (0)