diff --git a/packages/app_center/assets/error.svg b/packages/app_center/assets/error.svg
new file mode 100644
index 000000000..10fe9ddce
--- /dev/null
+++ b/packages/app_center/assets/error.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/app_center/lib/deb/deb_page.dart b/packages/app_center/lib/deb/deb_page.dart
index 78af5d3d9..7de80099c 100644
--- a/packages/app_center/lib/deb/deb_page.dart
+++ b/packages/app_center/lib/deb/deb_page.dart
@@ -4,6 +4,7 @@ import 'package:app_center/appstream/appstream.dart';
import 'package:app_center/constants.dart';
import 'package:app_center/deb/deb_model.dart';
import 'package:app_center/deb/deb_providers.dart';
+import 'package:app_center/error/error.dart';
import 'package:app_center/l10n.dart';
import 'package:app_center/layout.dart';
import 'package:app_center/packagekit/packagekit.dart';
@@ -59,7 +60,10 @@ class _DebPageState extends ConsumerState {
debModel: debModel,
),
),
- error: (error, stackTrace) => ErrorWidget(error),
+ error: (error, stackTrace) => ErrorView(
+ error: error,
+ onRetry: () => ref.invalidate(debModelProvider(widget.id)),
+ ),
loading: () => const Center(child: YaruCircularProgressIndicator()),
);
}
diff --git a/packages/app_center/lib/deb/local_deb_page.dart b/packages/app_center/lib/deb/local_deb_page.dart
index b9cc4de04..22ca6ae0f 100644
--- a/packages/app_center/lib/deb/local_deb_page.dart
+++ b/packages/app_center/lib/deb/local_deb_page.dart
@@ -1,5 +1,6 @@
import 'package:app_center/constants.dart';
import 'package:app_center/deb/local_deb_model.dart';
+import 'package:app_center/error/error.dart';
import 'package:app_center/l10n.dart';
import 'package:app_center/layout.dart';
import 'package:app_center/widgets/widgets.dart';
@@ -25,7 +26,10 @@ class LocalDebPage extends ConsumerWidget {
return model.when(
data: (debData) => _LocalDebPage(debData: debData),
loading: () => const Center(child: YaruCircularProgressIndicator()),
- error: (error, stackTrace) => ErrorWidget(error),
+ error: (error, stackTrace) => ErrorView(
+ error: error,
+ onRetry: () => ref.invalidate(localDebModelProvider(path: path)),
+ ),
);
}
}
diff --git a/packages/app_center/lib/error/error.dart b/packages/app_center/lib/error/error.dart
index 1028bde1c..cb8554e1c 100644
--- a/packages/app_center/lib/error/error.dart
+++ b/packages/app_center/lib/error/error.dart
@@ -1 +1,3 @@
+export 'error_l10n.dart';
export 'error_observer.dart';
+export 'error_view.dart';
diff --git a/packages/app_center/lib/error/error_l10n.dart b/packages/app_center/lib/error/error_l10n.dart
new file mode 100644
index 000000000..1e43e63cd
--- /dev/null
+++ b/packages/app_center/lib/error/error_l10n.dart
@@ -0,0 +1,82 @@
+import 'package:app_center/l10n.dart';
+import 'package:snapd/snapd.dart';
+
+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 ErrorMessageNetwork();
+ }
+ for (final patternMap in _patternMaps) {
+ final match = patternMap.pattern.firstMatch(e.message);
+ if (match != null) {
+ return patternMap.message(match);
+ }
+ }
+ 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 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 {}
diff --git a/packages/app_center/lib/error/error_view.dart b/packages/app_center/lib/error/error_view.dart
new file mode 100644
index 000000000..543885a00
--- /dev/null
+++ b/packages/app_center/lib/error/error_view.dart
@@ -0,0 +1,63 @@
+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:url_launcher/url_launcher_string.dart';
+
+class ErrorView extends StatelessWidget {
+ 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 = ErrorMessage.fromObject(error);
+
+ return Padding(
+ padding: const EdgeInsets.all(kPagePadding),
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ const Spacer(),
+ SvgPicture.asset('assets/error.svg'),
+ Text(
+ message.title(l10n),
+ style: Theme.of(context).textTheme.titleLarge,
+ ),
+ const SizedBox(height: kPagePadding),
+ Flexible(child: Text(message.body(l10n))),
+ if (message.actions.isNotEmpty) ...[
+ Flexible(child: Text(message.actionLabel(l10n))),
+ const SizedBox(height: kPagePadding),
+ ],
+ Row(
+ mainAxisSize: MainAxisSize.min,
+ 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: 3),
+ ],
+ ),
+ );
+ }
+}
diff --git a/packages/app_center/lib/manage/manage_page.dart b/packages/app_center/lib/manage/manage_page.dart
index 0e429b936..8161c1fcc 100644
--- a/packages/app_center/lib/manage/manage_page.dart
+++ b/packages/app_center/lib/manage/manage_page.dart
@@ -1,6 +1,7 @@
import 'dart:async';
import 'package:app_center/constants.dart';
+import 'package:app_center/error/error.dart';
import 'package:app_center/l10n.dart';
import 'package:app_center/layout.dart';
import 'package:app_center/manage/local_snap_providers.dart';
@@ -55,7 +56,7 @@ class _ManagePageState extends ConsumerState {
final manageModel = ref.watch(manageModelProvider);
return manageModel.state.when(
data: (_) => _ManageView(manageModel: manageModel),
- error: (error, stack) => ErrorWidget(error),
+ error: (error, stack) => ErrorView(error: error),
loading: () => const Center(child: YaruCircularProgressIndicator()),
);
}
diff --git a/packages/app_center/lib/search/search_page.dart b/packages/app_center/lib/search/search_page.dart
index 5db4d121a..65acc845c 100644
--- a/packages/app_center/lib/search/search_page.dart
+++ b/packages/app_center/lib/search/search_page.dart
@@ -1,4 +1,5 @@
import 'package:app_center/appstream/appstream.dart';
+import 'package:app_center/error/error.dart';
import 'package:app_center/l10n.dart';
import 'package:app_center/layout.dart';
import 'package:app_center/search/search.dart';
@@ -232,7 +233,10 @@ class _DebSearchResults extends ConsumerWidget {
],
),
),
- error: (error, stack) => ErrorWidget(error),
+ error: (error, stack) => ErrorView(
+ error: error,
+ onRetry: () => ref.invalidate(appstreamSearchProvider(query ?? '')),
+ ),
loading: () => const Center(child: YaruCircularProgressIndicator()),
);
}
@@ -294,7 +298,19 @@ class _SnapSearchResults extends ConsumerWidget {
],
),
),
- error: (error, stack) => ErrorWidget(error),
+ error: (error, stack) => ErrorView(
+ error: error,
+ onRetry: () {
+ ref.invalidate(
+ snapSearchProvider(
+ SnapSearchParameters(
+ query: query,
+ category: category,
+ ),
+ ),
+ );
+ },
+ ),
loading: () => const Center(child: YaruCircularProgressIndicator()),
);
}
diff --git a/packages/app_center/lib/snapd/snap_action.dart b/packages/app_center/lib/snapd/snap_action.dart
index b4a6aa03b..1ea212af8 100644
--- a/packages/app_center/lib/snapd/snap_action.dart
+++ b/packages/app_center/lib/snapd/snap_action.dart
@@ -1,5 +1,4 @@
import 'package:app_center/l10n.dart';
-import 'package:app_center/snapd/snap_data.dart';
import 'package:app_center/snapd/snapd.dart';
import 'package:flutter/widgets.dart';
import 'package:yaru/icons.dart';
diff --git a/packages/app_center/lib/snapd/snap_model.dart b/packages/app_center/lib/snapd/snap_model.dart
index 45b188b17..185a49f29 100644
--- a/packages/app_center/lib/snapd/snap_model.dart
+++ b/packages/app_center/lib/snapd/snap_model.dart
@@ -1,6 +1,5 @@
import 'dart:async';
-import 'package:app_center/snapd/snap_data.dart';
import 'package:app_center/snapd/snapd.dart';
import 'package:app_center/snapd/snapd_cache.dart';
import 'package:collection/collection.dart';
@@ -26,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)
diff --git a/packages/app_center/lib/snapd/snap_model.g.dart b/packages/app_center/lib/snapd/snap_model.g.dart
index 35488eea6..cad092ded 100644
--- a/packages/app_center/lib/snapd/snap_model.g.dart
+++ b/packages/app_center/lib/snapd/snap_model.g.dart
@@ -6,7 +6,7 @@ part of 'snap_model.dart';
// RiverpodGenerator
// **************************************************************************
-String _$snapModelHash() => r'fb77fb4987e65704071ba6dae0fae5a2dd99dd2b';
+String _$snapModelHash() => r'ff068b85e25f4e16765c867e3096dfce06bcf742';
/// Copied from Dart SDK
class _SystemHash {
diff --git a/packages/app_center/lib/snapd/snap_page.dart b/packages/app_center/lib/snapd/snap_page.dart
index 6bb80a3ff..ed16e4bb2 100644
--- a/packages/app_center/lib/snapd/snap_page.dart
+++ b/packages/app_center/lib/snapd/snap_page.dart
@@ -1,11 +1,12 @@
import 'package:app_center/constants.dart';
+import 'package:app_center/error/error.dart';
import 'package:app_center/l10n.dart';
import 'package:app_center/layout.dart';
import 'package:app_center/ratings/ratings.dart';
import 'package:app_center/snapd/snap_action.dart';
-import 'package:app_center/snapd/snap_data.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';
@@ -44,7 +45,10 @@ class SnapPage extends ConsumerWidget {
);
},
),
- error: (error, stackTrace) => ErrorWidget(error),
+ error: (error, stackTrace) => ErrorView(
+ error: error,
+ onRetry: () => ref.invalidate(storeSnapProvider(snapName)),
+ ),
loading: () => const Center(child: YaruCircularProgressIndicator()),
);
}
diff --git a/packages/app_center/lib/snapd/snapd.dart b/packages/app_center/lib/snapd/snapd.dart
index 57360f023..7ae1804fd 100644
--- a/packages/app_center/lib/snapd/snapd.dart
+++ b/packages/app_center/lib/snapd/snapd.dart
@@ -1,4 +1,5 @@
export 'snap_category_enum.dart';
+export 'snap_data.dart';
export 'snap_l10n.dart';
export 'snap_launcher.dart';
export 'snap_model.dart';
diff --git a/packages/app_center/lib/src/l10n/app_en.arb b/packages/app_center/lib/src/l10n/app_en.arb
index 61d6d7d88..2b570b135 100644
--- a/packages/app_center/lib/src/l10n/app_en.arb
+++ b/packages/app_center/lib/src/l10n/app_en.arb
@@ -253,5 +253,22 @@
"localDebWarningBody": "This package is provided by a third party. Installing packages from outside the App Center can increase the risk to your system and personal data. Ensure you trust the source before proceeding.",
"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?"
+ "localDebDialogConfirmation": "Are you sure you want to install it?",
+ "snapdExceptionRunningApps": "We couldn't update {snapName} because it is currently running.",
+ "@snapdExceptionRunningApps": {
+ "placeholders": {
+ "snapName": {
+ "type": "String"
+ }
+ }
+ },
+ "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."
}
\ No newline at end of file
diff --git a/packages/app_center/lib/store/store_app.dart b/packages/app_center/lib/store/store_app.dart
index a9d1443ab..b1346a206 100644
--- a/packages/app_center/lib/store/store_app.dart
+++ b/packages/app_center/lib/store/store_app.dart
@@ -1,4 +1,5 @@
import 'package:app_center/deb/deb.dart';
+import 'package:app_center/error/error.dart';
import 'package:app_center/games/games.dart';
import 'package:app_center/l10n.dart';
import 'package:app_center/layout.dart';
@@ -87,8 +88,8 @@ class _StoreAppHome extends ConsumerWidget {
Future _showError(BuildContext context, SnapdException e) {
return showErrorDialog(
context: context,
- title: e.kind ?? 'Unknown Snapd Exception',
- message: e.message,
+ title: UbuntuLocalizations.of(context).errorLabel,
+ message: ErrorMessage.fromObject(e).body(AppLocalizations.of(context)),
);
}
diff --git a/packages/app_center/lib/widgets/iterable_extensions.dart b/packages/app_center/lib/widgets/iterable_extensions.dart
new file mode 100644
index 000000000..280f7fd1c
--- /dev/null
+++ b/packages/app_center/lib/widgets/iterable_extensions.dart
@@ -0,0 +1,10 @@
+import 'package:flutter/widgets.dart';
+
+extension WidgetIterableExtension on Iterable {
+ List separatedBy(Widget separator) {
+ return expand((item) sync* {
+ yield separator;
+ yield item;
+ }).skip(1).toList();
+ }
+}
diff --git a/packages/app_center/pubspec.yaml b/packages/app_center/pubspec.yaml
index 794e0d6a5..446d07b6c 100644
--- a/packages/app_center/pubspec.yaml
+++ b/packages/app_center/pubspec.yaml
@@ -25,6 +25,7 @@ dependencies:
sdk: flutter
flutter_markdown: ^0.6.16
flutter_riverpod: ^2.5.1
+ flutter_svg: ^2.0.10+1
freezed_annotation: ^2.4.1
github: ^9.24.0
glib: ^0.0.1
diff --git a/packages/app_center/test/error_l10n_test.dart b/packages/app_center/test/error_l10n_test.dart
new file mode 100644
index 000000000..7e472840d
--- /dev/null
+++ b/packages/app_center/test/error_l10n_test.dart
@@ -0,0 +1,70 @@
+import 'package:app_center/error/error.dart';
+import 'package:app_center/error/error_l10n.dart';
+import 'package:app_center/l10n.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:snapd/snapd.dart';
+
+import 'test_utils.dart';
+
+void main() {
+ group('SnapdException', () {
+ final testCases = <({
+ String name,
+ SnapdException exception,
+ String Function(AppLocalizations l10n) expectedTitle,
+ String Function(AppLocalizations l10n) expectedBody,
+ String Function(AppLocalizations l10n) expectedActionLabel,
+ List expectedActions,
+ })>[
+ (
+ name: 'network timeout',
+ exception: SnapdException(kind: 'network-timeout', message: 'message'),
+ 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'),
+ expectedTitle: (l10n) => l10n.errorViewUnknownErrorTitle,
+ expectedBody: (l10n) => l10n.errorViewServerErrorDescription,
+ expectedActionLabel: (l10n) => l10n.errorViewServerErrorAction,
+ expectedActions: [ErrorAction.checkStatus],
+ ),
+ (
+ name: 'running apps',
+ exception: SnapdException(
+ kind: 'error',
+ message:
+ 'cannot refresh "testsnap": snap "testsnap" has running apps (testapp)',
+ ),
+ 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(
+ 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);
+ });
+ }
+ });
+}
diff --git a/packages/app_center/test/manage_page_test.dart b/packages/app_center/test/manage_page_test.dart
index ef2af9adc..e4a1ddf2d 100644
--- a/packages/app_center/test/manage_page_test.dart
+++ b/packages/app_center/test/manage_page_test.dart
@@ -1,7 +1,6 @@
import 'package:app_center/manage/local_snap_providers.dart';
import 'package:app_center/manage/manage.dart';
import 'package:app_center/manage/manage_model.dart';
-import 'package:app_center/snapd/snap_data.dart';
import 'package:app_center/snapd/snapd.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
diff --git a/packages/app_center/test/store_app_test.dart b/packages/app_center/test/store_app_test.dart
index a219f118d..76f16a0b0 100644
--- a/packages/app_center/test/store_app_test.dart
+++ b/packages/app_center/test/store_app_test.dart
@@ -1,5 +1,6 @@
import 'dart:async';
+import 'package:app_center/error/error_l10n.dart';
import 'package:app_center/providers/error_stream_provider.dart';
import 'package:app_center/ratings/ratings.dart';
import 'package:app_center/snapd/snapd.dart';
@@ -16,6 +17,7 @@ import 'package:yaru/yaru.dart';
import 'test_utils.dart';
void main() {
+ tearDown(resetAllServices);
group('updates badge', () {
testWidgets('no updates available', (tester) async {
registerMockService(
@@ -72,6 +74,9 @@ void main() {
testWidgets(
'errorStreamProvider receives exception when thrown',
(tester) async {
+ registerMockService(
+ createMockGtkApplicationNotifier(),
+ );
final snapdService = registerMockSnapdService();
registerService(ErrorStreamController.new);
@@ -108,13 +113,16 @@ void main() {
testWidgets('showing error from error stream', (tester) async {
registerMockSnapdService();
+ registerMockService(
+ createMockGtkApplicationNotifier(),
+ );
+ final error =
+ SnapdException(message: 'error message', kind: 'error kind');
await tester.pumpApp(
(_) => ProviderScope(
overrides: [
errorStreamProvider.overrideWith(
- (ref) => Stream.value(
- SnapdException(message: 'error message', kind: 'error kind'),
- ),
+ (ref) => Stream.value(error),
),
],
child: const StoreApp(),
@@ -122,8 +130,10 @@ void main() {
);
await tester.pump();
- expect(find.text('error message'), findsOneWidget);
- expect(find.text('error kind'), findsOneWidget);
+ expect(
+ find.text(ErrorMessage.fromObject(error).body(tester.l10n)),
+ findsOneWidget,
+ );
});
});
}