Kate
1 year ago
committed by
GitHub
35 changed files with 312 additions and 270 deletions
@ -1,11 +1,48 @@
|
||||
import 'package:flutter/material.dart'; |
||||
import 'package:neon/l10n/localizations.dart'; |
||||
import 'package:neon/src/models/label_builder.dart'; |
||||
import 'package:permission_handler/permission_handler.dart'; |
||||
|
||||
class MissingPermissionException implements Exception { |
||||
MissingPermissionException(this.permission); |
||||
/// Details of a [NeonException]. |
||||
class NeonExceptionDetails { |
||||
/// Creates new [NeonExceptionDetails]. |
||||
/// |
||||
/// [isUnauthorized] defaults to false. |
||||
const NeonExceptionDetails({ |
||||
required this.getText, |
||||
this.isUnauthorized = false, |
||||
}); |
||||
|
||||
final Permission permission; |
||||
/// Text that will be displayed in the UI |
||||
final LabelBuilder getText; |
||||
|
||||
/// If the [Exception] is the result of an unauthorized API request this should be set to `true`. |
||||
/// |
||||
/// The user will then be shown a button to update the credentials of the account instead of retrying the action. |
||||
final bool isUnauthorized; |
||||
} |
||||
|
||||
/// Extensible [Exception] to be used for displaying custom errors in the UI. |
||||
@immutable |
||||
abstract class NeonException implements Exception { |
||||
/// Creates a NeonException |
||||
const NeonException(); |
||||
|
||||
/// Details that will be rendered by the UI. |
||||
NeonExceptionDetails get details; |
||||
} |
||||
|
||||
class UnableToOpenFileException implements Exception {} |
||||
/// [Exception] that should be thrown when a native permission is denied. |
||||
class MissingPermissionException extends NeonException { |
||||
/// Creates a MissingPermissionException |
||||
const MissingPermissionException(this.permission); |
||||
|
||||
/// Permission that was denied |
||||
final Permission permission; |
||||
|
||||
class InvalidQRcodeException implements Exception {} |
||||
@override |
||||
NeonExceptionDetails get details => NeonExceptionDetails( |
||||
getText: (final context) => |
||||
AppLocalizations.of(context).errorMissingPermission(permission.toString().split('.')[1]), |
||||
); |
||||
} |
||||
|
@ -0,0 +1,191 @@
|
||||
import 'dart:async'; |
||||
import 'dart:io'; |
||||
|
||||
import 'package:flutter/material.dart'; |
||||
import 'package:http/http.dart'; |
||||
import 'package:meta/meta.dart'; |
||||
import 'package:neon/l10n/localizations.dart'; |
||||
import 'package:neon/src/blocs/accounts.dart'; |
||||
import 'package:neon/src/router.dart'; |
||||
import 'package:neon/src/utils/exceptions.dart'; |
||||
import 'package:neon/src/utils/provider.dart'; |
||||
import 'package:nextcloud/nextcloud.dart'; |
||||
|
||||
/// An indicator that an [error] has occurred. |
||||
/// |
||||
/// The action that lead to the error can be retried. |
||||
class NeonError extends StatelessWidget { |
||||
/// Creates a NeonError. |
||||
const NeonError( |
||||
this.error, { |
||||
required this.onRetry, |
||||
this.onlyIcon = false, |
||||
this.iconSize, |
||||
this.color, |
||||
super.key, |
||||
}); |
||||
|
||||
/// The error object. |
||||
/// |
||||
/// Can be of type [String] or [Exception], various subtypes of `Exception` are also handled separately. |
||||
final Object? error; |
||||
|
||||
/// A function that's called when the user decides to retry the action that lead to the error. |
||||
final VoidCallback onRetry; |
||||
|
||||
/// Changes whether the text is displayed additionally or not. |
||||
final bool onlyIcon; |
||||
|
||||
/// The size of the icon in logical pixels. |
||||
/// |
||||
/// Defaults to a size of `30`. |
||||
final double? iconSize; |
||||
|
||||
/// The color to use when drawing the error indicator. |
||||
/// |
||||
/// Defaults to the nearest [IconTheme]'s [ColorScheme.error]. |
||||
final Color? color; |
||||
|
||||
/// Shows a [SnackBar] popup for the [error]. |
||||
static void showSnackbar(final BuildContext context, final Object? error) { |
||||
final details = getDetails(error); |
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar( |
||||
SnackBar( |
||||
content: Text(details.getText(context)), |
||||
action: details.isUnauthorized |
||||
? SnackBarAction( |
||||
label: AppLocalizations.of(context).loginAgain, |
||||
onPressed: () => _openLoginPage(context), |
||||
) |
||||
: null, |
||||
), |
||||
); |
||||
} |
||||
|
||||
@override |
||||
Widget build(final BuildContext context) { |
||||
if (error == null) { |
||||
return const SizedBox(); |
||||
} |
||||
|
||||
final details = getDetails(error); |
||||
final color = this.color ?? Theme.of(context).colorScheme.error; |
||||
|
||||
final errorIcon = Icon( |
||||
Icons.error_outline, |
||||
size: iconSize ?? 30, |
||||
color: color, |
||||
); |
||||
|
||||
final message = |
||||
details.isUnauthorized ? AppLocalizations.of(context).loginAgain : AppLocalizations.of(context).actionRetry; |
||||
|
||||
final onPressed = details.isUnauthorized ? () => _openLoginPage(context) : onRetry; |
||||
|
||||
if (onlyIcon) { |
||||
return Semantics( |
||||
tooltip: details.getText(context), |
||||
child: IconButton( |
||||
icon: errorIcon, |
||||
padding: EdgeInsets.zero, |
||||
visualDensity: const VisualDensity( |
||||
horizontal: VisualDensity.minimumDensity, |
||||
vertical: VisualDensity.minimumDensity, |
||||
), |
||||
tooltip: message, |
||||
onPressed: onPressed, |
||||
), |
||||
); |
||||
} |
||||
|
||||
return Padding( |
||||
padding: const EdgeInsets.all(5), |
||||
child: Column( |
||||
mainAxisAlignment: MainAxisAlignment.center, |
||||
children: [ |
||||
Row( |
||||
mainAxisAlignment: MainAxisAlignment.center, |
||||
children: [ |
||||
errorIcon, |
||||
const SizedBox( |
||||
width: 10, |
||||
), |
||||
Flexible( |
||||
child: Text( |
||||
details.getText(context), |
||||
style: TextStyle( |
||||
color: color, |
||||
), |
||||
), |
||||
), |
||||
], |
||||
), |
||||
ElevatedButton( |
||||
onPressed: onPressed, |
||||
child: Text(message), |
||||
), |
||||
], |
||||
), |
||||
); |
||||
} |
||||
|
||||
/// Gets the details for a given [error]. |
||||
@internal |
||||
static NeonExceptionDetails getDetails(final Object? error) { |
||||
switch (error) { |
||||
case String(): |
||||
return NeonExceptionDetails( |
||||
getText: (final _) => error, |
||||
); |
||||
case NeonException(): |
||||
return error.details; |
||||
case DynamiteApiException(): |
||||
if (error.statusCode == 401) { |
||||
return NeonExceptionDetails( |
||||
getText: (final context) => AppLocalizations.of(context).errorCredentialsForAccountNoLongerMatch, |
||||
isUnauthorized: true, |
||||
); |
||||
} |
||||
if (error.statusCode >= 500 && error.statusCode <= 599) { |
||||
return NeonExceptionDetails( |
||||
getText: (final context) => AppLocalizations.of(context).errorServerHadAProblemProcessingYourRequest, |
||||
); |
||||
} |
||||
case SocketException(): |
||||
return NeonExceptionDetails( |
||||
getText: (final context) => error.address != null |
||||
? AppLocalizations.of(context).errorUnableToReachServerAt(error.address!.host) |
||||
: AppLocalizations.of(context).errorUnableToReachServer, |
||||
); |
||||
case ClientException(): |
||||
return NeonExceptionDetails( |
||||
getText: (final context) => error.uri != null |
||||
? AppLocalizations.of(context).errorUnableToReachServerAt(error.uri!.host) |
||||
: AppLocalizations.of(context).errorUnableToReachServer, |
||||
); |
||||
case HttpException(): |
||||
return NeonExceptionDetails( |
||||
getText: (final context) => error.uri != null |
||||
? AppLocalizations.of(context).errorUnableToReachServerAt(error.uri!.host) |
||||
: AppLocalizations.of(context).errorUnableToReachServer, |
||||
); |
||||
case TimeoutException(): |
||||
return NeonExceptionDetails( |
||||
getText: (final context) => AppLocalizations.of(context).errorConnectionTimedOut, |
||||
); |
||||
} |
||||
|
||||
return NeonExceptionDetails( |
||||
getText: (final context) => AppLocalizations.of(context).errorSomethingWentWrongTryAgainLater, |
||||
); |
||||
} |
||||
|
||||
static void _openLoginPage(final BuildContext context) { |
||||
unawaited( |
||||
LoginCheckServerStatusRoute( |
||||
serverUrl: NeonProvider.of<AccountsBloc>(context).activeAccount.value!.serverURL, |
||||
).push(context), |
||||
); |
||||
} |
||||
} |
@ -1,207 +0,0 @@
|
||||
import 'dart:async'; |
||||
import 'dart:io'; |
||||
|
||||
import 'package:flutter/material.dart'; |
||||
import 'package:http/http.dart'; |
||||
import 'package:meta/meta.dart'; |
||||
import 'package:neon/l10n/localizations.dart'; |
||||
import 'package:neon/src/blocs/accounts.dart'; |
||||
import 'package:neon/src/router.dart'; |
||||
import 'package:neon/src/utils/exceptions.dart'; |
||||
import 'package:neon/src/utils/provider.dart'; |
||||
import 'package:nextcloud/nextcloud.dart'; |
||||
|
||||
class NeonException extends StatelessWidget { |
||||
const NeonException( |
||||
this.exception, { |
||||
required this.onRetry, |
||||
this.onlyIcon = false, |
||||
this.iconSize, |
||||
this.color, |
||||
super.key, |
||||
}); |
||||
|
||||
final dynamic exception; |
||||
final VoidCallback onRetry; |
||||
final bool onlyIcon; |
||||
final double? iconSize; |
||||
final Color? color; |
||||
|
||||
static void showSnackbar(final BuildContext context, final dynamic exception) { |
||||
final details = getDetails(context, exception); |
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar( |
||||
SnackBar( |
||||
content: Text(details.text), |
||||
action: details.isUnauthorized |
||||
? SnackBarAction( |
||||
label: AppLocalizations.of(context).loginAgain, |
||||
onPressed: () => _openLoginPage(context), |
||||
) |
||||
: null, |
||||
), |
||||
); |
||||
} |
||||
|
||||
@override |
||||
Widget build(final BuildContext context) { |
||||
if (exception == null) { |
||||
return const SizedBox(); |
||||
} |
||||
|
||||
final details = getDetails(context, exception); |
||||
final color = this.color ?? Theme.of(context).colorScheme.error; |
||||
|
||||
final errorIcon = Icon( |
||||
Icons.error_outline, |
||||
size: iconSize ?? 30, |
||||
color: color, |
||||
); |
||||
|
||||
final message = |
||||
details.isUnauthorized ? AppLocalizations.of(context).loginAgain : AppLocalizations.of(context).actionRetry; |
||||
|
||||
final onPressed = details.isUnauthorized ? () => _openLoginPage(context) : onRetry; |
||||
|
||||
if (onlyIcon) { |
||||
return Semantics( |
||||
tooltip: details.text, |
||||
child: IconButton( |
||||
icon: errorIcon, |
||||
padding: EdgeInsets.zero, |
||||
visualDensity: const VisualDensity( |
||||
horizontal: VisualDensity.minimumDensity, |
||||
vertical: VisualDensity.minimumDensity, |
||||
), |
||||
tooltip: message, |
||||
onPressed: onPressed, |
||||
), |
||||
); |
||||
} |
||||
|
||||
return Padding( |
||||
padding: const EdgeInsets.all(5), |
||||
child: Column( |
||||
mainAxisAlignment: MainAxisAlignment.center, |
||||
children: [ |
||||
Row( |
||||
mainAxisAlignment: MainAxisAlignment.center, |
||||
children: [ |
||||
errorIcon, |
||||
const SizedBox( |
||||
width: 10, |
||||
), |
||||
Flexible( |
||||
child: Text( |
||||
details.text, |
||||
style: TextStyle( |
||||
color: color, |
||||
), |
||||
), |
||||
), |
||||
], |
||||
), |
||||
ElevatedButton( |
||||
onPressed: onPressed, |
||||
child: Text(message), |
||||
), |
||||
], |
||||
), |
||||
); |
||||
} |
||||
|
||||
@internal |
||||
static ExceptionDetails getDetails(final BuildContext context, final dynamic exception) { |
||||
if (exception is String) { |
||||
return ExceptionDetails( |
||||
text: exception, |
||||
); |
||||
} |
||||
|
||||
if (exception is MissingPermissionException) { |
||||
return ExceptionDetails( |
||||
text: AppLocalizations.of(context).errorMissingPermission(exception.permission.toString().split('.')[1]), |
||||
); |
||||
} |
||||
|
||||
if (exception is UnableToOpenFileException) { |
||||
return ExceptionDetails( |
||||
text: AppLocalizations.of(context).errorUnableToOpenFile, |
||||
); |
||||
} |
||||
|
||||
if (exception is InvalidQRcodeException) { |
||||
return ExceptionDetails( |
||||
text: AppLocalizations.of(context).errorInvalidQRcode, |
||||
); |
||||
} |
||||
|
||||
if (exception is DynamiteApiException) { |
||||
if (exception.statusCode == 401) { |
||||
return ExceptionDetails( |
||||
text: AppLocalizations.of(context).errorCredentialsForAccountNoLongerMatch, |
||||
isUnauthorized: true, |
||||
); |
||||
} |
||||
|
||||
if (exception.statusCode >= 500 && exception.statusCode <= 599) { |
||||
return ExceptionDetails( |
||||
text: AppLocalizations.of(context).errorServerHadAProblemProcessingYourRequest, |
||||
); |
||||
} |
||||
} |
||||
|
||||
if (exception is SocketException) { |
||||
return ExceptionDetails( |
||||
text: exception.address != null |
||||
? AppLocalizations.of(context).errorUnableToReachServerAt(exception.address!.host) |
||||
: AppLocalizations.of(context).errorUnableToReachServer, |
||||
); |
||||
} |
||||
|
||||
if (exception is ClientException) { |
||||
return ExceptionDetails( |
||||
text: exception.uri != null |
||||
? AppLocalizations.of(context).errorUnableToReachServerAt(exception.uri!.host) |
||||
: AppLocalizations.of(context).errorUnableToReachServer, |
||||
); |
||||
} |
||||
|
||||
if (exception is HttpException) { |
||||
return ExceptionDetails( |
||||
text: exception.uri != null |
||||
? AppLocalizations.of(context).errorUnableToReachServerAt(exception.uri!.host) |
||||
: AppLocalizations.of(context).errorUnableToReachServer, |
||||
); |
||||
} |
||||
|
||||
if (exception is TimeoutException) { |
||||
return ExceptionDetails( |
||||
text: AppLocalizations.of(context).errorConnectionTimedOut, |
||||
); |
||||
} |
||||
|
||||
return ExceptionDetails( |
||||
text: AppLocalizations.of(context).errorSomethingWentWrongTryAgainLater, |
||||
); |
||||
} |
||||
|
||||
static void _openLoginPage(final BuildContext context) { |
||||
unawaited( |
||||
LoginCheckServerStatusRoute( |
||||
serverUrl: NeonProvider.of<AccountsBloc>(context).activeAccount.value!.serverURL, |
||||
).push(context), |
||||
); |
||||
} |
||||
} |
||||
|
||||
@internal |
||||
class ExceptionDetails { |
||||
ExceptionDetails({ |
||||
required this.text, |
||||
this.isUnauthorized = false, |
||||
}); |
||||
|
||||
final String text; |
||||
final bool isUnauthorized; |
||||
} |
Loading…
Reference in new issue