From 6a6898de75a25fd23db06951a68b5d944aebe6fa Mon Sep 17 00:00:00 2001 From: Nikolas Rimikis Date: Thu, 15 Jun 2023 20:16:21 +0200 Subject: [PATCH] RFC: errorBloc --- packages/neon/neon/lib/src/blocs/apps.dart | 42 +++ packages/neon/neon/lib/src/blocs/blocs.dart | 2 + packages/neon/neon/lib/src/blocs/error.dart | 88 ++++++ packages/neon/neon/lib/src/pages/home.dart | 321 +++++++++----------- 4 files changed, 269 insertions(+), 184 deletions(-) create mode 100644 packages/neon/neon/lib/src/blocs/error.dart diff --git a/packages/neon/neon/lib/src/blocs/apps.dart b/packages/neon/neon/lib/src/blocs/apps.dart index ff0e5d7a..70c1d3bb 100644 --- a/packages/neon/neon/lib/src/blocs/apps.dart +++ b/packages/neon/neon/lib/src/blocs/apps.dart @@ -50,6 +50,8 @@ class AppsBloc extends InteractiveBloc implements AppsBlocEvents, AppsBlocStates } }), ); + + unawaited(_checkCompatibility()); } }); @@ -59,11 +61,51 @@ class AppsBloc extends InteractiveBloc implements AppsBlocEvents, AppsBlocStates (final data) => data.capabilities.notifications != null ? _findAppImplementation('notifications') : null, ), ); + + unawaited(_checkCompatibility()); }); unawaited(refresh()); } + Future _checkCompatibility() async { + final apps = appImplementations.valueOrNull; + final capabilities = _capabilitiesBloc.capabilities.valueOrNull; + + if (capabilities == null || apps == null) { + return; + } + + final appIds = { + 'core', + ...apps.data!.map((final a) => a.id), + }; + + final notSupported = <(String, Object?)>{}; + + for (final id in appIds) { + try { + final (supported, minVersion) = switch (id) { + 'core' => await _account.client.core.isSupported(capabilities.data), + 'news' => await _account.client.news.isSupported(), + 'notes' => await _account.client.notes.isSupported(capabilities.data), + _ => (true, null), + }; + + if (!supported) { + notSupported.add((id, minVersion)); + } + } catch (e, s) { + debugPrint(e.toString()); + debugPrint(s.toString()); + } + } + + if (notSupported.isNotEmpty) { + ErrorBloc().addVersionErrors(notSupported); + } + } + T? _findAppImplementation(final String id) { final matches = _filteredAppImplementations([id]); if (matches.isNotEmpty) { diff --git a/packages/neon/neon/lib/src/blocs/blocs.dart b/packages/neon/neon/lib/src/blocs/blocs.dart index e411e525..432ffabd 100644 --- a/packages/neon/neon/lib/src/blocs/blocs.dart +++ b/packages/neon/neon/lib/src/blocs/blocs.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:convert'; import 'package:flutter/foundation.dart'; +import 'package:neon/l10n/localizations.dart'; import 'package:neon/neon.dart'; import 'package:nextcloud/nextcloud.dart'; import 'package:package_info_plus/package_info_plus.dart'; @@ -14,6 +15,7 @@ import 'package:window_manager/window_manager.dart'; part 'accounts.dart'; part 'apps.dart'; part 'capabilities.dart'; +part 'error.dart'; part 'first_launch.dart'; part 'login.dart'; part 'next_push.dart'; diff --git a/packages/neon/neon/lib/src/blocs/error.dart b/packages/neon/neon/lib/src/blocs/error.dart new file mode 100644 index 00000000..bab6a0ea --- /dev/null +++ b/packages/neon/neon/lib/src/blocs/error.dart @@ -0,0 +1,88 @@ +part of 'blocs.dart'; + +typedef TranslationCallback = String Function(AppLocalizations l10n); + +abstract class ErrorBlocEvents { + /// Adds an error to the [ErrorBlocStates.globalErrors]. + /// + /// Used to signal non app specific errors. + void addGlobalError(final String message); + + /// Adds an error to the [ErrorBlocStates.appErrors]. + /// + /// Used to signal errors specific to an app identified by [appId]. + void addAppError(final String appId, final String message); +} + +abstract class ErrorBlocStates { + /// Errors for the global neon framework. + BehaviorSubject get globalErrors; + + /// Errors for a specific app. + Map> get appErrors; +} + +/// Holds error messages to be displayed by the UI +/// +/// It will cache the last emmited error. +/// The [ErrorBloc] is a singleton. +class ErrorBloc extends Bloc implements ErrorBlocEvents, ErrorBlocStates { + factory ErrorBloc() => instance ??= ErrorBloc._(); + + @visibleForTesting + factory ErrorBloc.mocked(final ErrorBloc mock) => instance ??= mock; + + ErrorBloc._(); + + @visibleForTesting + static ErrorBloc? instance; + + AppLocalizations? l10n; + + @override + final BehaviorSubject globalErrors = BehaviorSubject(); + @override + final Map> appErrors = {}; + + @override + void dispose() { + ErrorBloc.instance = null; + } + + @override + void addGlobalError(final String message) { + globalErrors.add(message); + } + + @override + void addAppError(final String appId, final String message) { + if (appErrors[appId] == null) { + appErrors[appId] = BehaviorSubject(); + } + + appErrors[appId]!.add(message); + } + + void addVersionErrors(final Iterable<(String, Object?)> errors) { + assert(l10n != null, 'Localization must be register to process version Errors.'); + + final buffer = StringBuffer(); + + for (final error in errors) { + // TODO: reword errorUnsupportedVersion to support multiple errors + // TODO: add version info + + final (appId, minVersion) = error; + final appName = l10n!.appImplementationName(appId); + final message = l10n!.errorUnsupportedVersion(appName); + + buffer.write(message); + } + + if (buffer.isNotEmpty) { + addGlobalError(buffer.toString()); + } + } + + String translateError(final TranslationCallback callback) => callback(l10n!); +} diff --git a/packages/neon/neon/lib/src/pages/home.dart b/packages/neon/neon/lib/src/pages/home.dart index e8df2256..762c2a47 100644 --- a/packages/neon/neon/lib/src/pages/home.dart +++ b/packages/neon/neon/lib/src/pages/home.dart @@ -11,7 +11,6 @@ class HomePage extends StatefulWidget { State createState() => _HomePageState(); } -// ignore: prefer_mixin class _HomePageState extends State { final _scaffoldKey = GlobalKey(); final drawerScrollController = ScrollController(); @@ -20,7 +19,6 @@ class _HomePageState extends State { late GlobalOptions _globalOptions; late AccountsBloc _accountsBloc; late AppsBloc _appsBloc; - late CapabilitiesBloc _capabilitiesBloc; @override void initState() { @@ -29,7 +27,6 @@ class _HomePageState extends State { _accountsBloc = Provider.of(context, listen: false); _account = _accountsBloc.activeAccount.value!; _appsBloc = _accountsBloc.activeAppsBloc; - _capabilitiesBloc = _accountsBloc.activeCapabilitiesBloc; _appsBloc.openNotifications.listen((final _) async { final notificationsAppImplementation = _appsBloc.notificationsAppImplementation.valueOrNull; @@ -42,47 +39,6 @@ class _HomePageState extends State { } }); - _capabilitiesBloc.capabilities.listen((final result) async { - if (result.data != null) { - // ignore cached version and prevent duplicate dialogs - if (result.cached) { - return; - } - _appsBloc.appImplementations.listen((final appsResult) async { - // ignore cached version and prevent duplicate dialogs - if (appsResult.data == null || appsResult.cached) { - return; - } - for (final id in [ - 'core', - ...appsResult.data!.map((final a) => a.id), - ]) { - try { - final (supported, _) = switch (id) { - 'core' => await _account.client.core.isSupported(result.data), - 'news' => await _account.client.news.isSupported(), - 'notes' => await _account.client.notes.isSupported(result.data), - _ => (true, null), - }; - if (supported || !mounted) { - return; - } - var name = AppLocalizations.of(context).appImplementationName(id); - if (name == '') { - name = id; - } - await _showProblem( - AppLocalizations.of(context).errorUnsupportedVersion(name), - ); - } catch (e, s) { - debugPrint(e.toString()); - debugPrint(s.toString()); - } - } - }); - } - }); - GlobalPopups().register(context); unawaited(_checkMaintenanceMode()); @@ -168,164 +124,161 @@ class _HomePageState extends State { } @override - Widget build(final BuildContext context) => ResultBuilder( - stream: _capabilitiesBloc.capabilities, - builder: (final context, final capabilities) => ResultBuilder>( - stream: _appsBloc.appImplementations, - builder: (final context, final appImplementations) => ResultBuilder( - stream: _appsBloc.notificationsAppImplementation, - builder: (final context, final notificationsAppImplementation) => StreamBuilder( - stream: _appsBloc.activeAppID, - builder: (final context, final activeAppIDSnapshot) => StreamBuilder>( - stream: _accountsBloc.accounts, - builder: (final context, final accountsSnapshot) => OptionBuilder( - option: _globalOptions.navigationMode, - builder: (final context, final navigationMode) { - final accounts = accountsSnapshot.data; - final account = accounts?.find(_account.id); - if (accounts == null || account == null) { - return const Scaffold(); - } + Widget build(final BuildContext context) => ResultBuilder>( + stream: _appsBloc.appImplementations, + builder: (final context, final appImplementations) => ResultBuilder( + stream: _appsBloc.notificationsAppImplementation, + builder: (final context, final notificationsAppImplementation) => StreamBuilder( + stream: _appsBloc.activeAppID, + builder: (final context, final activeAppIDSnapshot) => StreamBuilder>( + stream: _accountsBloc.accounts, + builder: (final context, final accountsSnapshot) => OptionBuilder( + option: _globalOptions.navigationMode, + builder: (final context, final navigationMode) { + final accounts = accountsSnapshot.data; + final account = accounts?.find(_account.id); + if (accounts == null || account == null) { + return const Scaffold(); + } - final drawerAlwaysVisible = navigationMode == NavigationMode.drawerAlwaysVisible; + final drawerAlwaysVisible = navigationMode == NavigationMode.drawerAlwaysVisible; - const drawer = NeonDrawer(); - final appBar = AppBar( - automaticallyImplyLeading: !drawerAlwaysVisible, - title: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - if (appImplementations.data != null && activeAppIDSnapshot.hasData) ...[ - Flexible( - child: Text( - appImplementations.data!.find(activeAppIDSnapshot.data!)!.name(context), - ), - ), - ], - if (appImplementations.error != null) ...[ - const SizedBox( - width: 8, - ), - NeonException( - appImplementations.error, - onRetry: _appsBloc.refresh, - onlyIcon: true, - ), - ], - if (appImplementations.loading) ...[ - const SizedBox( - width: 8, + const drawer = NeonDrawer(); + final appBar = AppBar( + automaticallyImplyLeading: !drawerAlwaysVisible, + title: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + if (appImplementations.data != null && activeAppIDSnapshot.hasData) ...[ + Flexible( + child: Text( + appImplementations.data!.find(activeAppIDSnapshot.data!)!.name(context), ), - Expanded( - child: NeonLinearProgressIndicator( - color: Theme.of(context).appBarTheme.foregroundColor, - ), + ), + ], + if (appImplementations.error != null) ...[ + const SizedBox( + width: 8, + ), + NeonException( + appImplementations.error, + onRetry: _appsBloc.refresh, + onlyIcon: true, + ), + ], + if (appImplementations.loading) ...[ + const SizedBox( + width: 8, + ), + Expanded( + child: NeonLinearProgressIndicator( + color: Theme.of(context).appBarTheme.foregroundColor, ), - ], + ), ], - ), - if (accounts.length > 1) ...[ - Text( - account.client.humanReadableID, - style: Theme.of(context).textTheme.bodySmall, - ), ], - ], - ), - actions: [ - if (notificationsAppImplementation.data != null) ...[ - StreamBuilder( - stream: notificationsAppImplementation.data! - .getUnreadCounter(notificationsAppImplementation.data!.getBloc(account)), - builder: (final context, final unreadCounterSnapshot) { - final unreadCount = unreadCounterSnapshot.data ?? 0; - return IconButton( - key: Key('app-${notificationsAppImplementation.data!.id}'), - onPressed: () async { - await _openNotifications( - notificationsAppImplementation.data!, - accounts, - account, - ); - }, - tooltip: AppLocalizations.of(context) - .appImplementationName(notificationsAppImplementation.data!.id), - icon: NeonAppImplementationIcon( - appImplementation: notificationsAppImplementation.data!, - unreadCount: unreadCount, - color: unreadCount > 0 - ? Theme.of(context).colorScheme.primary - : Theme.of(context).colorScheme.onBackground, - size: const Size.square(kAvatarSize * 2 / 3), - ), - ); - }, + ), + if (accounts.length > 1) ...[ + Text( + account.client.humanReadableID, + style: Theme.of(context).textTheme.bodySmall, ), ], - IconButton( - onPressed: () { - AccountSettingsRoute(accountid: account.id).go(context); + ], + ), + actions: [ + if (notificationsAppImplementation.data != null) ...[ + StreamBuilder( + stream: notificationsAppImplementation.data! + .getUnreadCounter(notificationsAppImplementation.data!.getBloc(account)), + builder: (final context, final unreadCounterSnapshot) { + final unreadCount = unreadCounterSnapshot.data ?? 0; + return IconButton( + key: Key('app-${notificationsAppImplementation.data!.id}'), + onPressed: () async { + await _openNotifications( + notificationsAppImplementation.data!, + accounts, + account, + ); + }, + tooltip: AppLocalizations.of(context) + .appImplementationName(notificationsAppImplementation.data!.id), + icon: NeonAppImplementationIcon( + appImplementation: notificationsAppImplementation.data!, + unreadCount: unreadCount, + color: unreadCount > 0 + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.onBackground, + size: const Size.square(kAvatarSize * 2 / 3), + ), + ); }, - tooltip: AppLocalizations.of(context).settingsAccount, - icon: NeonUserAvatar( - account: account, - ), ), ], - ); + IconButton( + onPressed: () { + AccountSettingsRoute(accountid: account.id).go(context); + }, + tooltip: AppLocalizations.of(context).settingsAccount, + icon: NeonUserAvatar( + account: account, + ), + ), + ], + ); - Widget body = Builder( - builder: (final context) { - if (appImplementations.data == null) { - return const SizedBox.shrink(); - } + Widget body = Builder( + builder: (final context) { + if (appImplementations.data == null) { + return const SizedBox.shrink(); + } - if (appImplementations.data!.isEmpty) { - return const NoAppsPage(); - } + if (appImplementations.data!.isEmpty) { + return const NoAppsPage(); + } - return appImplementations.data!.find(activeAppIDSnapshot.data!)!.page; - }, - ); + return appImplementations.data!.find(activeAppIDSnapshot.data!)!.page; + }, + ); - body = MultiProvider( - providers: _appsBloc.appBlocProviders, - child: Scaffold( - key: _scaffoldKey, - resizeToAvoidBottomInset: false, - drawer: !drawerAlwaysVisible ? drawer : null, - appBar: appBar, - body: body, - ), - ); + body = MultiProvider( + providers: _appsBloc.appBlocProviders, + child: Scaffold( + key: _scaffoldKey, + resizeToAvoidBottomInset: false, + drawer: !drawerAlwaysVisible ? drawer : null, + appBar: appBar, + body: body, + ), + ); - if (drawerAlwaysVisible) { - body = Row( - children: [ - drawer, - Expanded( - child: body, - ), - ], - ); - } + if (drawerAlwaysVisible) { + body = Row( + children: [ + drawer, + Expanded( + child: body, + ), + ], + ); + } - return WillPopScope( - onWillPop: () async { - if (_scaffoldKey.currentState!.isDrawerOpen) { - Navigator.pop(context); - return true; - } + return WillPopScope( + onWillPop: () async { + if (_scaffoldKey.currentState!.isDrawerOpen) { + Navigator.pop(context); + return true; + } - _scaffoldKey.currentState!.openDrawer(); - return false; - }, - child: body, - ); - }, - ), + _scaffoldKey.currentState!.openDrawer(); + return false; + }, + child: body, + ); + }, ), ), ),