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, + ); }); }); }