Skip to content

Commit 1c095b2

Browse files
gnpricePIG208
authored andcommitted
log: Support reportErrorToUserBriefly
The motivation of having this indirection, rather than using `showErrorDialog` and `showSnackBar` directly, is to keep dependencies of `lib/log.dart` from relying on widget code. Signed-off-by: Zixuan James Li <[email protected]>
1 parent 4256349 commit 1c095b2

File tree

4 files changed

+191
-0
lines changed

4 files changed

+191
-0
lines changed

assets/l10n/app_en.arb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,10 @@
293293
"@errorDialogTitle": {
294294
"description": "Generic title for error dialog."
295295
},
296+
"snackBarDetails": "Details",
297+
"@snackBarDetails": {
298+
"description": "Button label for snack bar button that opens a dialog with more details."
299+
},
296300
"lightboxCopyLinkTooltip": "Copy link",
297301
"@lightboxCopyLinkTooltip": {
298302
"description": "Tooltip in lightbox for the copy link action."

lib/log.dart

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,31 @@ bool debugLog(String message) {
3030
}());
3131
return true;
3232
}
33+
34+
typedef ReportErrorCallback = void Function(String? message, {String? details});
35+
36+
/// Display an error message in a [SnackBar].
37+
///
38+
/// This shows a [SnackBar] containing the message if [ZulipApp] is ready,
39+
/// otherwise logs it to the console.
40+
///
41+
/// If `message` is null, this will clear the existing [SnackBar]s if there
42+
/// are any. Useful for promptly dismissing errors.
43+
///
44+
/// If `details` is non-null, the [SnackBar] will contain a button that would
45+
/// open a dialog containing the error details.
46+
// This gets set in [ZulipApp]. We need this indirection to keep `lib/log.dart`
47+
// from importing widget code, because the file is a dependency for the rest of
48+
// the app.
49+
ReportErrorCallback reportErrorToUserBriefly = defaultReportErrorToUserBriefly;
50+
51+
void defaultReportErrorToUserBriefly(String? message, {String? details}) {
52+
// Error dismissing is a no-op to the default handler.
53+
if (message == null) return;
54+
// If this callback is still in place, then the app's widget tree
55+
// hasn't mounted yet even as far as the [Navigator].
56+
// So there's not much we can do to tell the user;
57+
// just log, in case the user is actually a developer watching the console.
58+
assert(debugLog(message));
59+
if (details != null) assert(debugLog(details));
60+
}

lib/widgets/app.dart

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,12 @@ import 'package:flutter/material.dart';
55
import 'package:flutter/scheduler.dart';
66
import 'package:flutter_gen/gen_l10n/zulip_localizations.dart';
77

8+
import '../log.dart';
89
import '../model/localizations.dart';
910
import '../model/narrow.dart';
1011
import 'about_zulip.dart';
1112
import 'app_bar.dart';
13+
import 'dialog.dart';
1214
import 'inbox.dart';
1315
import 'login.dart';
1416
import 'message_list.dart';
@@ -84,6 +86,8 @@ class ZulipApp extends StatefulWidget {
8486
/// TODO refactor this better, perhaps unify with ZulipBinding
8587
@visibleForTesting
8688
static void debugReset() {
89+
_snackBarCount = 0;
90+
reportErrorToUserBriefly = defaultReportErrorToUserBriefly;
8791
_ready.dispose();
8892
_ready = ValueNotifier(false);
8993
}
@@ -92,9 +96,46 @@ class ZulipApp extends StatefulWidget {
9296
/// Useful in tests.
9397
final List<NavigatorObserver>? navigatorObservers;
9498

99+
static int _snackBarCount = 0;
100+
101+
static void _reportErrorToUserBriefly(String? message, {String? details}) {
102+
assert(_ready.value);
103+
104+
if (message == null) {
105+
if (_snackBarCount > 0) {
106+
// We make sure that there remain some active snack bars created with
107+
// this helper before clearing all snack bars.
108+
//
109+
// This does not prevent us from dismissing unrelated snack bars.
110+
// However, this situation is supposedly rare, and the SnackBar API does
111+
// not support dismissing some selected snack bars.
112+
scaffoldMessenger!.clearSnackBars();
113+
}
114+
return;
115+
}
116+
117+
final localizations = ZulipLocalizations.of(navigatorKey.currentContext!);
118+
final newSnackBar = scaffoldMessenger!.showSnackBar(
119+
snackBarAnimationStyle: AnimationStyle(
120+
duration: const Duration(milliseconds: 200),
121+
reverseDuration: const Duration(milliseconds: 50)),
122+
SnackBar(
123+
content: Text(message),
124+
action: (details == null)
125+
? null
126+
: SnackBarAction(label: localizations.snackBarDetails,
127+
onPressed: () => showErrorDialog(context: navigatorKey.currentContext!,
128+
title: localizations.errorDialogTitle,
129+
message: details))));
130+
131+
_snackBarCount++;
132+
newSnackBar.closed.whenComplete(() => _snackBarCount--);
133+
}
134+
95135
void _declareReady() {
96136
assert(navigatorKey.currentContext != null);
97137
_ready.value = true;
138+
reportErrorToUserBriefly = _reportErrorToUserBriefly;
98139
}
99140

100141
@override

test/widgets/app_test.dart

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import 'package:checks/checks.dart';
22
import 'package:flutter/material.dart';
33
import 'package:flutter_test/flutter_test.dart';
4+
import 'package:zulip/log.dart';
45
import 'package:zulip/model/database.dart';
56
import 'package:zulip/widgets/app.dart';
67
import 'package:zulip/widgets/inbox.dart';
@@ -10,6 +11,7 @@ import '../example_data.dart' as eg;
1011
import '../flutter_checks.dart';
1112
import '../model/binding.dart';
1213
import '../test_navigation.dart';
14+
import 'dialog_checks.dart';
1315
import 'page_checks.dart';
1416
import 'test_app.dart';
1517

@@ -169,5 +171,121 @@ void main() {
169171
check(ZulipApp.scaffoldMessenger).isNotNull();
170172
check(ZulipApp.ready).value.isTrue();
171173
});
174+
175+
Finder findSnackBarByText(String text) => find.descendant(
176+
of: find.byType(SnackBar),
177+
matching: find.text(text));
178+
179+
testWidgets('reportErrorToUserBriefly', (tester) async {
180+
addTearDown(testBinding.reset);
181+
await tester.pumpWidget(const ZulipApp());
182+
const message = 'test error message';
183+
184+
// Prior to app startup, reportErrorToUserBriefly only logs.
185+
reportErrorToUserBriefly(message);
186+
check(ZulipApp.ready).value.isFalse();
187+
await tester.pump();
188+
check(findSnackBarByText(message).evaluate()).isEmpty();
189+
190+
check(ZulipApp.ready).value.isTrue();
191+
// After app startup, reportErrorToUserBriefly displays a SnackBar.
192+
reportErrorToUserBriefly(message);
193+
await tester.pump();
194+
check(findSnackBarByText(message).evaluate()).single;
195+
check(find.text('Details').evaluate()).isEmpty();
196+
});
197+
198+
testWidgets('reportErrorToUserBriefly with details', (tester) async {
199+
addTearDown(testBinding.reset);
200+
await tester.pumpWidget(const ZulipApp());
201+
const message = 'test error message';
202+
const details = 'error details';
203+
204+
// Prior to app startup, reportErrorToUserBriefly only logs.
205+
reportErrorToUserBriefly(message, details: details);
206+
check(ZulipApp.ready).value.isFalse();
207+
await tester.pump();
208+
check(findSnackBarByText(message).evaluate()).isEmpty();
209+
check(find.byType(AlertDialog).evaluate()).isEmpty();
210+
211+
check(ZulipApp.ready).value.isTrue();
212+
// After app startup, reportErrorToUserBriefly displays a SnackBar.
213+
reportErrorToUserBriefly(message, details: details);
214+
await tester.pumpAndSettle();
215+
check(findSnackBarByText(message).evaluate()).single;
216+
check(find.byType(AlertDialog).evaluate()).isEmpty();
217+
218+
// Open the error details dialog.
219+
await tester.tap(find.text('Details'));
220+
await tester.pumpAndSettle();
221+
check(findSnackBarByText(message).evaluate()).isEmpty();
222+
checkErrorDialog(tester, expectedTitle: 'Error', expectedMessage: details);
223+
});
224+
225+
Future<void> prepareSnackBarWithDetails(WidgetTester tester, String message, String details) async {
226+
addTearDown(testBinding.reset);
227+
await tester.pumpWidget(const ZulipApp());
228+
await tester.pump();
229+
check(ZulipApp.ready).value.isTrue();
230+
231+
reportErrorToUserBriefly(message, details: details);
232+
await tester.pumpAndSettle();
233+
check(findSnackBarByText(message).evaluate()).single;
234+
}
235+
236+
testWidgets('reportErrorToUser dismissing SnackBar', (tester) async {
237+
const message = 'test error message';
238+
const details = 'error details';
239+
await prepareSnackBarWithDetails(tester, message, details);
240+
241+
// Dismissing the SnackBar.
242+
reportErrorToUserBriefly(null);
243+
await tester.pumpAndSettle();
244+
check(findSnackBarByText(message).evaluate()).isEmpty();
245+
246+
// Verify that the SnackBar would otherwise stay when not dismissed.
247+
reportErrorToUserBriefly(message, details: details);
248+
await tester.pumpAndSettle();
249+
check(findSnackBarByText(message).evaluate()).single;
250+
await tester.pumpAndSettle();
251+
check(findSnackBarByText(message).evaluate()).single;
252+
});
253+
254+
testWidgets('reportErrorToUserBriefly(null) does not dismiss dialog', (tester) async {
255+
const message = 'test error message';
256+
const details = 'error details';
257+
await prepareSnackBarWithDetails(tester, message, details);
258+
259+
// Open the error details dialog.
260+
await tester.tap(find.text('Details'));
261+
await tester.pumpAndSettle();
262+
check(findSnackBarByText(message).evaluate()).isEmpty();
263+
checkErrorDialog(tester, expectedTitle: 'Error', expectedMessage: details);
264+
265+
// The dialog should not get dismissed.
266+
reportErrorToUserBriefly(null);
267+
await tester.pumpAndSettle();
268+
checkErrorDialog(tester, expectedTitle: 'Error', expectedMessage: details);
269+
});
270+
271+
testWidgets('reportErrorToUserBriefly(null) does not dismiss unrelated SnackBar', (tester) async {
272+
const message = 'test error message';
273+
const details = 'error details';
274+
await prepareSnackBarWithDetails(tester, message, details);
275+
276+
// Dismissing the SnackBar.
277+
reportErrorToUserBriefly(null);
278+
await tester.pumpAndSettle();
279+
check(findSnackBarByText(message).evaluate()).isEmpty();
280+
281+
// Unrelated SnackBars should not be dismissed.
282+
ZulipApp.scaffoldMessenger!.showSnackBar(
283+
const SnackBar(content: Text ('unrelated')));
284+
await tester.pumpAndSettle();
285+
check(findSnackBarByText('unrelated').evaluate()).single;
286+
reportErrorToUserBriefly(null);
287+
await tester.pumpAndSettle();
288+
check(findSnackBarByText('unrelated').evaluate()).single;
289+
});
172290
});
173291
}

0 commit comments

Comments
 (0)