Browse Source

RFC: errorBloc

pull/390/head
Nikolas Rimikis 2 years ago
parent
commit
6a6898de75
No known key found for this signature in database
GPG Key ID: 85ED1DE9786A4FF2
  1. 42
      packages/neon/neon/lib/src/blocs/apps.dart
  2. 2
      packages/neon/neon/lib/src/blocs/blocs.dart
  3. 88
      packages/neon/neon/lib/src/blocs/error.dart
  4. 321
      packages/neon/neon/lib/src/pages/home.dart

42
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, (final data) => data.capabilities.notifications != null ? _findAppImplementation('notifications') : null,
), ),
); );
unawaited(_checkCompatibility());
}); });
unawaited(refresh()); unawaited(refresh());
} }
Future<void> _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<T extends AppImplementation>(final String id) { T? _findAppImplementation<T extends AppImplementation>(final String id) {
final matches = _filteredAppImplementations([id]); final matches = _filteredAppImplementations([id]);
if (matches.isNotEmpty) { if (matches.isNotEmpty) {

2
packages/neon/neon/lib/src/blocs/blocs.dart

@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:neon/l10n/localizations.dart';
import 'package:neon/neon.dart'; import 'package:neon/neon.dart';
import 'package:nextcloud/nextcloud.dart'; import 'package:nextcloud/nextcloud.dart';
import 'package:package_info_plus/package_info_plus.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 'accounts.dart';
part 'apps.dart'; part 'apps.dart';
part 'capabilities.dart'; part 'capabilities.dart';
part 'error.dart';
part 'first_launch.dart'; part 'first_launch.dart';
part 'login.dart'; part 'login.dart';
part 'next_push.dart'; part 'next_push.dart';

88
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<String> get globalErrors;
/// Errors for a specific app.
Map<String, BehaviorSubject<String>> 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<String> globalErrors = BehaviorSubject();
@override
final Map<String, BehaviorSubject<String>> 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!);
}

321
packages/neon/neon/lib/src/pages/home.dart

@ -11,7 +11,6 @@ class HomePage extends StatefulWidget {
State<HomePage> createState() => _HomePageState(); State<HomePage> createState() => _HomePageState();
} }
// ignore: prefer_mixin
class _HomePageState extends State<HomePage> { class _HomePageState extends State<HomePage> {
final _scaffoldKey = GlobalKey<ScaffoldState>(); final _scaffoldKey = GlobalKey<ScaffoldState>();
final drawerScrollController = ScrollController(); final drawerScrollController = ScrollController();
@ -20,7 +19,6 @@ class _HomePageState extends State<HomePage> {
late GlobalOptions _globalOptions; late GlobalOptions _globalOptions;
late AccountsBloc _accountsBloc; late AccountsBloc _accountsBloc;
late AppsBloc _appsBloc; late AppsBloc _appsBloc;
late CapabilitiesBloc _capabilitiesBloc;
@override @override
void initState() { void initState() {
@ -29,7 +27,6 @@ class _HomePageState extends State<HomePage> {
_accountsBloc = Provider.of<AccountsBloc>(context, listen: false); _accountsBloc = Provider.of<AccountsBloc>(context, listen: false);
_account = _accountsBloc.activeAccount.value!; _account = _accountsBloc.activeAccount.value!;
_appsBloc = _accountsBloc.activeAppsBloc; _appsBloc = _accountsBloc.activeAppsBloc;
_capabilitiesBloc = _accountsBloc.activeCapabilitiesBloc;
_appsBloc.openNotifications.listen((final _) async { _appsBloc.openNotifications.listen((final _) async {
final notificationsAppImplementation = _appsBloc.notificationsAppImplementation.valueOrNull; final notificationsAppImplementation = _appsBloc.notificationsAppImplementation.valueOrNull;
@ -42,47 +39,6 @@ class _HomePageState extends State<HomePage> {
} }
}); });
_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); GlobalPopups().register(context);
unawaited(_checkMaintenanceMode()); unawaited(_checkMaintenanceMode());
@ -168,164 +124,161 @@ class _HomePageState extends State<HomePage> {
} }
@override @override
Widget build(final BuildContext context) => ResultBuilder<Capabilities>( Widget build(final BuildContext context) => ResultBuilder<Iterable<AppImplementation>>(
stream: _capabilitiesBloc.capabilities, stream: _appsBloc.appImplementations,
builder: (final context, final capabilities) => ResultBuilder<Iterable<AppImplementation>>( builder: (final context, final appImplementations) => ResultBuilder<NotificationsAppInterface?>(
stream: _appsBloc.appImplementations, stream: _appsBloc.notificationsAppImplementation,
builder: (final context, final appImplementations) => ResultBuilder<NotificationsAppInterface?>( builder: (final context, final notificationsAppImplementation) => StreamBuilder<String?>(
stream: _appsBloc.notificationsAppImplementation, stream: _appsBloc.activeAppID,
builder: (final context, final notificationsAppImplementation) => StreamBuilder<String?>( builder: (final context, final activeAppIDSnapshot) => StreamBuilder<List<Account>>(
stream: _appsBloc.activeAppID, stream: _accountsBloc.accounts,
builder: (final context, final activeAppIDSnapshot) => StreamBuilder<List<Account>>( builder: (final context, final accountsSnapshot) => OptionBuilder<NavigationMode>(
stream: _accountsBloc.accounts, option: _globalOptions.navigationMode,
builder: (final context, final accountsSnapshot) => OptionBuilder<NavigationMode>( builder: (final context, final navigationMode) {
option: _globalOptions.navigationMode, final accounts = accountsSnapshot.data;
builder: (final context, final navigationMode) { final account = accounts?.find(_account.id);
final accounts = accountsSnapshot.data; if (accounts == null || account == null) {
final account = accounts?.find(_account.id); return const Scaffold();
if (accounts == null || account == null) { }
return const Scaffold();
}
final drawerAlwaysVisible = navigationMode == NavigationMode.drawerAlwaysVisible; final drawerAlwaysVisible = navigationMode == NavigationMode.drawerAlwaysVisible;
const drawer = NeonDrawer(); const drawer = NeonDrawer();
final appBar = AppBar( final appBar = AppBar(
automaticallyImplyLeading: !drawerAlwaysVisible, automaticallyImplyLeading: !drawerAlwaysVisible,
title: Column( title: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Row( Row(
children: [ children: [
if (appImplementations.data != null && activeAppIDSnapshot.hasData) ...[ if (appImplementations.data != null && activeAppIDSnapshot.hasData) ...[
Flexible( Flexible(
child: Text( child: Text(
appImplementations.data!.find(activeAppIDSnapshot.data!)!.name(context), 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,
), ),
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,
),
], ],
], ),
), if (accounts.length > 1) ...[
actions: [ Text(
if (notificationsAppImplementation.data != null) ...[ account.client.humanReadableID,
StreamBuilder<int>( style: Theme.of(context).textTheme.bodySmall,
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),
),
);
},
), ),
], ],
IconButton( ],
onPressed: () { ),
AccountSettingsRoute(accountid: account.id).go(context); actions: [
if (notificationsAppImplementation.data != null) ...[
StreamBuilder<int>(
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( Widget body = Builder(
builder: (final context) { builder: (final context) {
if (appImplementations.data == null) { if (appImplementations.data == null) {
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
if (appImplementations.data!.isEmpty) { if (appImplementations.data!.isEmpty) {
return const NoAppsPage(); return const NoAppsPage();
} }
return appImplementations.data!.find(activeAppIDSnapshot.data!)!.page; return appImplementations.data!.find(activeAppIDSnapshot.data!)!.page;
}, },
); );
body = MultiProvider( body = MultiProvider(
providers: _appsBloc.appBlocProviders, providers: _appsBloc.appBlocProviders,
child: Scaffold( child: Scaffold(
key: _scaffoldKey, key: _scaffoldKey,
resizeToAvoidBottomInset: false, resizeToAvoidBottomInset: false,
drawer: !drawerAlwaysVisible ? drawer : null, drawer: !drawerAlwaysVisible ? drawer : null,
appBar: appBar, appBar: appBar,
body: body, body: body,
), ),
); );
if (drawerAlwaysVisible) { if (drawerAlwaysVisible) {
body = Row( body = Row(
children: [ children: [
drawer, drawer,
Expanded( Expanded(
child: body, child: body,
), ),
], ],
); );
} }
return WillPopScope( return WillPopScope(
onWillPop: () async { onWillPop: () async {
if (_scaffoldKey.currentState!.isDrawerOpen) { if (_scaffoldKey.currentState!.isDrawerOpen) {
Navigator.pop(context); Navigator.pop(context);
return true; return true;
} }
_scaffoldKey.currentState!.openDrawer(); _scaffoldKey.currentState!.openDrawer();
return false; return false;
}, },
child: body, child: body,
); );
}, },
),
), ),
), ),
), ),

Loading…
Cancel
Save