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'; |
import 'package:permission_handler/permission_handler.dart'; |
||||||
|
|
||||||
class MissingPermissionException implements Exception { |
/// Details of a [NeonException]. |
||||||
MissingPermissionException(this.permission); |
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