Skip to content

Commit

Permalink
feat: match figma designs for error pages
Browse files Browse the repository at this point in the history
- add `ErrorMessage` structure for localized error title, body and
  actions
- add retry logic where possible
- link to snapcraft status page for server errors
  • Loading branch information
d-loose committed Jul 10, 2024
1 parent abcd42e commit a37548c
Show file tree
Hide file tree
Showing 12 changed files with 193 additions and 56 deletions.
5 changes: 4 additions & 1 deletion packages/app_center/lib/deb/deb_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,10 @@ class _DebPageState extends ConsumerState<DebPage> {
debModel: debModel,
),
),
error: (error, stackTrace) => ErrorView(error: error),
error: (error, stackTrace) => ErrorView(
error: error,
onRetry: () => ref.invalidate(debModelProvider(widget.id)),
),
loading: () => const Center(child: YaruCircularProgressIndicator()),
);
}
Expand Down
5 changes: 4 additions & 1 deletion packages/app_center/lib/deb/local_deb_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,10 @@ class LocalDebPage extends ConsumerWidget {
return model.when(
data: (debData) => _LocalDebPage(debData: debData),
loading: () => const Center(child: YaruCircularProgressIndicator()),
error: (error, stackTrace) => ErrorView(error: error),
error: (error, stackTrace) => ErrorView(
error: error,
onRetry: () => ref.invalidate(localDebModelProvider(path: path)),
),
);
}
}
Expand Down
97 changes: 71 additions & 26 deletions packages/app_center/lib/error/error_l10n.dart
Original file line number Diff line number Diff line change
@@ -1,37 +1,82 @@
import 'package:app_center/l10n.dart';
import 'package:snapd/snapd.dart';

typedef PatternMap = ({
RegExp pattern,
String Function(AppLocalizations l10n, RegExpMatch match) message,
});

final _patternMaps = <PatternMap>[
(
pattern: RegExp('too many requests'),
message: (l10n, _) => l10n.snapdExceptionTooManyRequests,
),
(
pattern:
RegExp(r'cannot refresh "(.*?)": snap "\1" has running apps \((.*?)\)'),
message: (l10n, match) => l10n.snapdExceptionRunningApps(
match.group(1).toString(),
),
),
];

extension SnapdExceptionL10n on SnapdException {
String prettyFormat(AppLocalizations l10n) {
switch (kind) {
enum ErrorAction {
retry,
checkStatus,
}

sealed class ErrorMessage {
const ErrorMessage();

factory ErrorMessage.fromObject(Object? e) {
if (e is! SnapdException) return ErrorMessageUnkown();

switch (e.kind) {
case 'network-timeout':
return l10n.snapdExceptionNetworkTimeout;
return ErrorMessageNetwork();
}
for (final patternMap in _patternMaps) {
final match = patternMap.pattern.firstMatch(message);
final match = patternMap.pattern.firstMatch(e.message);
if (match != null) {
return patternMap.message(l10n, match);
return patternMap.message(match);
}
}
return message;
return ErrorMessageUnkown();
}

static final _patternMaps =
<({RegExp pattern, ErrorMessage Function(Match) message})>[
(
pattern: RegExp('too many requests'),
message: (_) => ErrorMessageTooManyRequests(),
),
(
pattern: RegExp(
r'cannot refresh "(.*?)": snap "\1" has running apps \((.*?)\)',
),
message: (match) => ErrorMessageRunningApps(match.group(1)!),
),
(
pattern: RegExp('persistent network error'),
message: (_) => ErrorMessageNetwork(),
),
];

String body(AppLocalizations l10n) => switch (this) {
ErrorMessageNetwork() => l10n.errorViewNetworkErrorDescription,
ErrorMessageTooManyRequests() => l10n.errorViewServerErrorDescription,
ErrorMessageRunningApps(snap: final snap) =>
l10n.snapdExceptionRunningApps(snap),
_ => l10n.errorViewUnknownErrorDescription,
};

String title(AppLocalizations l10n) => switch (this) {
ErrorMessageNetwork() => l10n.errorViewNetworkErrorTitle,
_ => l10n.errorViewUnknownErrorTitle,
};

String actionLabel(AppLocalizations l10n) => switch (this) {
ErrorMessageNetwork() => l10n.errorViewNetworkErrorAction,
ErrorMessageTooManyRequests() => l10n.errorViewServerErrorAction,
_ => l10n.errorViewUnknownErrorAction,
};

List<ErrorAction> get actions => switch (this) {
ErrorMessageNetwork() => [ErrorAction.retry],
ErrorMessageTooManyRequests() => [ErrorAction.checkStatus],
ErrorMessageRunningApps() => [],
_ => [ErrorAction.retry, ErrorAction.checkStatus],
};
}

class ErrorMessageNetwork extends ErrorMessage {}

class ErrorMessageTooManyRequests extends ErrorMessage {}

class ErrorMessageRunningApps extends ErrorMessage {
const ErrorMessageRunningApps(this.snap);
final String snap;
}

class ErrorMessageUnkown extends ErrorMessage {}
38 changes: 30 additions & 8 deletions packages/app_center/lib/error/error_view.dart
Original file line number Diff line number Diff line change
@@ -1,28 +1,32 @@
import 'package:app_center/error/error.dart';
import 'package:app_center/l10n.dart';
import 'package:app_center/layout.dart';
import 'package:app_center/widgets/iterable_extensions.dart';
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:snapd/snapd.dart';
import 'package:url_launcher/url_launcher_string.dart';

class ErrorView extends StatelessWidget {
const ErrorView({super.key, this.error, this.stackTrace});
const ErrorView({super.key, this.error, this.stackTrace, this.onRetry});

static const statusUrl = 'https://status.snapcraft.io/';

final Object? error;
final StackTrace? stackTrace;
final VoidCallback? onRetry;

@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final message = switch (error) {
final SnapdException e => e.prettyFormat(l10n),
_ => l10n.errorViewUnknownError,
};
final message = ErrorMessage.fromObject(error);

return Padding(
padding: const EdgeInsets.all(kPagePadding),
child: Column(
children: [
const Spacer(),
Flexible(
flex: 2,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Expand All @@ -33,15 +37,33 @@ class ErrorView extends StatelessWidget {
mainAxisSize: MainAxisSize.min,
children: [
Text(
l10n.errorViewTitle,
message.title(l10n),
style: Theme.of(context).textTheme.titleMedium,
),
Text(message),
Flexible(child: Text(message.body(l10n))),
const SizedBox(height: kPagePadding),
Row(
children: [
if (message.actions.contains(ErrorAction.retry))
OutlinedButton(
onPressed: onRetry,
child: Text(
UbuntuLocalizations.of(context).retryLabel,
),
),
if (message.actions.contains(ErrorAction.checkStatus))
OutlinedButton(
onPressed: () => launchUrlString(statusUrl),
child: Text(l10n.errorViewCheckStatusLabel),
),
].separatedBy(const SizedBox(width: 10)),
),
],
),
],
),
),
const Spacer(flex: 5),
],
),
);
Expand Down
19 changes: 17 additions & 2 deletions packages/app_center/lib/search/search_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -233,7 +233,10 @@ class _DebSearchResults extends ConsumerWidget {
],
),
),
error: (error, stack) => ErrorView(error: error),
error: (error, stack) => ErrorView(
error: error,
onRetry: () => ref.invalidate(appstreamSearchProvider(query ?? '')),
),
loading: () => const Center(child: YaruCircularProgressIndicator()),
);
}
Expand Down Expand Up @@ -295,7 +298,19 @@ class _SnapSearchResults extends ConsumerWidget {
],
),
),
error: (error, stack) => ErrorView(error: error),
error: (error, stack) => ErrorView(
error: error,
onRetry: () {
ref.invalidate(
snapSearchProvider(
SnapSearchParameters(
query: query,
category: category,
),
),
);
},
),
loading: () => const Center(child: YaruCircularProgressIndicator()),
);
}
Expand Down
2 changes: 1 addition & 1 deletion packages/app_center/lib/snapd/snap_model.dart
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ class SnapModel extends _$SnapModel {

final storeSnap = await ref
.watch(storeSnapProvider(snapName).future)
.onError((_, __) => null);
.onError((_, __) => null, test: (_) => localSnap != null);

final activeChangeId = (await _snapd.getChanges(name: snapName))
.firstWhereOrNull((change) => !change.ready)
Expand Down
6 changes: 5 additions & 1 deletion packages/app_center/lib/snapd/snap_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import 'package:app_center/ratings/ratings.dart';
import 'package:app_center/snapd/snap_action.dart';
import 'package:app_center/snapd/snap_report.dart';
import 'package:app_center/snapd/snapd.dart';
import 'package:app_center/snapd/snapd_cache.dart';
import 'package:app_center/store/store_app.dart';
import 'package:app_center/widgets/widgets.dart';
import 'package:collection/collection.dart';
Expand Down Expand Up @@ -44,7 +45,10 @@ class SnapPage extends ConsumerWidget {
);
},
),
error: (error, stackTrace) => ErrorView(error: error),
error: (error, stackTrace) => ErrorView(
error: error,
onRetry: () => ref.invalidate(storeSnapProvider(snapName)),
),
loading: () => const Center(child: YaruCircularProgressIndicator()),
);
}
Expand Down
13 changes: 9 additions & 4 deletions packages/app_center/lib/src/l10n/app_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,6 @@
"localDebLearnMore": "Learn more about third party packages",
"localDebDialogMessage": "This package is provided by a third party and may threaten your system and personal data.",
"localDebDialogConfirmation": "Are you sure you want to install it?",
"snapdExceptionTooManyRequests": "Too many requests. Please try again later.",
"snapdExceptionRunningApps": "We couldn't update {snapName} because it is currently running.",
"@snapdExceptionRunningApps": {
"placeholders": {
Expand All @@ -263,7 +262,13 @@
}
}
},
"snapdExceptionNetworkTimeout": "Network timeout. Please check your internet connection and try again.",
"errorViewTitle": "Something went wrong",
"errorViewUnknownError": "An unknown error occurred"
"errorViewCheckStatusLabel": "Check status",
"errorViewNetworkErrorTitle": "Connect to internet",
"errorViewNetworkErrorDescription": "We can't load content in the App Center without an internet connection.",
"errorViewNetworkErrorAction": "Check your connection and retry.",
"errorViewServerErrorDescription": "We're sorry, we are currently experiencing problems with the App Center.",
"errorViewServerErrorAction": "Check the status for updates or try again later.",
"errorViewUnknownErrorTitle": "Something went wrong",
"errorViewUnknownErrorDescription": "We're sorry, but we’re not sure what the error is.",
"errorViewUnknownErrorAction": "You can retry now, check the status for updates, or try again later."
}
2 changes: 1 addition & 1 deletion packages/app_center/lib/store/store_app.dart
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ class _StoreAppHome extends ConsumerWidget {
return showErrorDialog(
context: context,
title: UbuntuLocalizations.of(context).errorLabel,
message: e.prettyFormat(AppLocalizations.of(context)),
message: ErrorMessage.fromObject(e).body(AppLocalizations.of(context)),
);
}

Expand Down
10 changes: 10 additions & 0 deletions packages/app_center/lib/widgets/iterable_extensions.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import 'package:flutter/widgets.dart';

extension WidgetIterableExtension on Iterable<Widget> {
List<Widget> separatedBy(Widget separator) {
return expand((item) sync* {
yield separator;
yield item;
}).skip(1).toList();
}
}
36 changes: 29 additions & 7 deletions packages/app_center/test/error_l10n_test.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import 'package:app_center/error/error.dart';
import 'package:app_center/error/error_l10n.dart';
import 'package:app_center/l10n.dart';
import 'package:app_center/src/l10n/l10n.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:snapd/snapd.dart';
Expand All @@ -12,17 +12,26 @@ void main() {
final testCases = <({
String name,
SnapdException exception,
String Function(AppLocalizations) expected
String Function(AppLocalizations l10n) expectedTitle,
String Function(AppLocalizations l10n) expectedBody,
String Function(AppLocalizations l10n) expectedActionLabel,
List<ErrorAction> expectedActions,
})>[
(
name: 'network timeout',
exception: SnapdException(kind: 'network-timeout', message: 'message'),
expected: (l10n) => l10n.snapdExceptionNetworkTimeout
expectedTitle: (l10n) => l10n.errorViewNetworkErrorTitle,
expectedBody: (l10n) => l10n.errorViewNetworkErrorDescription,
expectedActionLabel: (l10n) => l10n.errorViewNetworkErrorAction,
expectedActions: [ErrorAction.retry],
),
(
name: 'too many requests',
exception: SnapdException(message: 'too many requests'),
expected: (l10n) => l10n.snapdExceptionTooManyRequests,
expectedTitle: (l10n) => l10n.errorViewUnknownErrorTitle,
expectedBody: (l10n) => l10n.errorViewServerErrorDescription,
expectedActionLabel: (l10n) => l10n.errorViewServerErrorAction,
expectedActions: [ErrorAction.checkStatus],
),
(
name: 'running apps',
Expand All @@ -31,17 +40,30 @@ void main() {
message:
'cannot refresh "testsnap": snap "testsnap" has running apps (testapp)',
),
expected: (l10n) => l10n.snapdExceptionRunningApps('testsnap')
expectedTitle: (l10n) => l10n.errorViewUnknownErrorTitle,
expectedBody: (l10n) => l10n.snapdExceptionRunningApps('testsnap'),
expectedActionLabel: (l10n) => l10n.errorViewUnknownErrorAction,
expectedActions: [],
),
];

for (final testCase in testCases) {
testWidgets(testCase.name, (tester) async {
await tester.pumpApp((context) => const Scaffold());
final message = ErrorMessage.fromObject(testCase.exception);
expect(
testCase.exception.prettyFormat(tester.l10n),
testCase.expected(tester.l10n),
message.title(tester.l10n),
testCase.expectedTitle(tester.l10n),
);
expect(
message.body(tester.l10n),
testCase.expectedBody(tester.l10n),
);
expect(
message.actionLabel(tester.l10n),
testCase.expectedActionLabel(tester.l10n),
);
expect(message.actions, testCase.expectedActions);
});
}
});
Expand Down
Loading

0 comments on commit a37548c

Please sign in to comment.