feat(sf_shared): add FailureType + handleFailure + feedback dialogs
This commit is contained in:
@@ -975,5 +975,18 @@
|
||||
"activityMeterNoStepsToday": "Heute noch keine Schritte erfasst",
|
||||
"activityMeterNoStepsPeriod": "Keine Aktivität in diesem Zeitraum",
|
||||
"unitStepsPerDay": "Schritte/Tag",
|
||||
"unitDays": "Tage"
|
||||
"unitDays": "Tage",
|
||||
"errorOfflineTitle": "Keine Verbindung",
|
||||
"errorOfflineMessage": "Überprüfe deine Internetverbindung und versuche es erneut.",
|
||||
"errorOfflineRetry": "Erneut versuchen",
|
||||
"errorTechnicalTitle": "Etwas ist schiefgelaufen",
|
||||
"errorTechnicalMessage": "Wir arbeiten daran. Bitte versuche es später erneut.",
|
||||
"errorInvalidCredentials": "Benutzername oder Passwort ist falsch.",
|
||||
"errorNotFound": "Wir konnten nicht finden, wonach du gesucht hast.",
|
||||
"errorRateLimit": "Zu viele Anfragen. Warte einige Minuten und versuche es erneut.",
|
||||
"errorSessionExpired": "Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.",
|
||||
"errorValidation": "Die eingegebenen Daten sind ungültig.",
|
||||
"errorScaRequired": "Eine zusätzliche Authentifizierung ist erforderlich.",
|
||||
"errorDeviceNotOwned": "Du hast keine Berechtigung, auf dieses Gerät zuzugreifen.",
|
||||
"errorGeneric": "Ein unerwarteter Fehler ist aufgetreten."
|
||||
}
|
||||
|
||||
@@ -975,5 +975,18 @@
|
||||
"activityMeterNoStepsToday": "No steps recorded yet today",
|
||||
"activityMeterNoStepsPeriod": "No activity recorded in this period",
|
||||
"unitStepsPerDay": "steps/day",
|
||||
"unitDays": "days"
|
||||
"unitDays": "days",
|
||||
"errorOfflineTitle": "No connection",
|
||||
"errorOfflineMessage": "Check your internet connection and try again.",
|
||||
"errorOfflineRetry": "Retry",
|
||||
"errorTechnicalTitle": "Something went wrong",
|
||||
"errorTechnicalMessage": "We are working on it. Please try again later.",
|
||||
"errorInvalidCredentials": "Incorrect username or password.",
|
||||
"errorNotFound": "We couldn't find what you were looking for.",
|
||||
"errorRateLimit": "Too many requests. Wait a few minutes and try again.",
|
||||
"errorSessionExpired": "Your session has expired. Please sign in again.",
|
||||
"errorValidation": "The information you entered is not valid.",
|
||||
"errorScaRequired": "Additional authentication is required to continue.",
|
||||
"errorDeviceNotOwned": "You don't have permission to access this device.",
|
||||
"errorGeneric": "An unexpected error occurred."
|
||||
}
|
||||
|
||||
@@ -975,5 +975,18 @@
|
||||
"activityMeterNoStepsToday": "Aún no se registraron pasos hoy",
|
||||
"activityMeterNoStepsPeriod": "No hay actividad registrada en este período",
|
||||
"unitStepsPerDay": "pasos/día",
|
||||
"unitDays": "días"
|
||||
"unitDays": "días",
|
||||
"errorOfflineTitle": "Sin conexión",
|
||||
"errorOfflineMessage": "Comprueba tu conexión a internet e inténtalo de nuevo.",
|
||||
"errorOfflineRetry": "Reintentar",
|
||||
"errorTechnicalTitle": "Algo salió mal",
|
||||
"errorTechnicalMessage": "Estamos trabajando para solucionarlo. Inténtalo más tarde.",
|
||||
"errorInvalidCredentials": "Usuario o contraseña incorrectos.",
|
||||
"errorNotFound": "No se encontró lo que buscabas.",
|
||||
"errorRateLimit": "Demasiadas solicitudes. Espera unos minutos e inténtalo de nuevo.",
|
||||
"errorSessionExpired": "Tu sesión ha caducado. Vuelve a iniciar sesión.",
|
||||
"errorValidation": "Los datos introducidos no son válidos.",
|
||||
"errorScaRequired": "Se requiere autenticación adicional para continuar.",
|
||||
"errorDeviceNotOwned": "No tienes permiso para acceder a este dispositivo.",
|
||||
"errorGeneric": "Ha ocurrido un error inesperado."
|
||||
}
|
||||
|
||||
@@ -975,5 +975,18 @@
|
||||
"activityMeterNoStepsToday": "Aucun pas enregistré aujourd'hui",
|
||||
"activityMeterNoStepsPeriod": "Aucune activité enregistrée sur cette période",
|
||||
"unitStepsPerDay": "pas/jour",
|
||||
"unitDays": "jours"
|
||||
"unitDays": "jours",
|
||||
"errorOfflineTitle": "Pas de connexion",
|
||||
"errorOfflineMessage": "Vérifiez votre connexion internet et réessayez.",
|
||||
"errorOfflineRetry": "Réessayer",
|
||||
"errorTechnicalTitle": "Une erreur s'est produite",
|
||||
"errorTechnicalMessage": "Nous travaillons à résoudre le problème. Réessayez plus tard.",
|
||||
"errorInvalidCredentials": "Nom d'utilisateur ou mot de passe incorrect.",
|
||||
"errorNotFound": "Nous n'avons pas trouvé ce que vous cherchiez.",
|
||||
"errorRateLimit": "Trop de requêtes. Attendez quelques minutes et réessayez.",
|
||||
"errorSessionExpired": "Votre session a expiré. Veuillez vous reconnecter.",
|
||||
"errorValidation": "Les données saisies ne sont pas valides.",
|
||||
"errorScaRequired": "Une authentification supplémentaire est requise pour continuer.",
|
||||
"errorDeviceNotOwned": "Vous n'avez pas l'autorisation d'accéder à cet appareil.",
|
||||
"errorGeneric": "Une erreur inattendue s'est produite."
|
||||
}
|
||||
|
||||
@@ -975,5 +975,18 @@
|
||||
"activityMeterNoStepsToday": "Nessun passo registrato oggi",
|
||||
"activityMeterNoStepsPeriod": "Nessuna attività registrata in questo periodo",
|
||||
"unitStepsPerDay": "passi/giorno",
|
||||
"unitDays": "giorni"
|
||||
"unitDays": "giorni",
|
||||
"errorOfflineTitle": "Nessuna connessione",
|
||||
"errorOfflineMessage": "Controlla la tua connessione internet e riprova.",
|
||||
"errorOfflineRetry": "Riprova",
|
||||
"errorTechnicalTitle": "Qualcosa è andato storto",
|
||||
"errorTechnicalMessage": "Stiamo lavorando per risolverlo. Riprova più tardi.",
|
||||
"errorInvalidCredentials": "Nome utente o password non corretti.",
|
||||
"errorNotFound": "Non abbiamo trovato ciò che cercavi.",
|
||||
"errorRateLimit": "Troppe richieste. Aspetta qualche minuto e riprova.",
|
||||
"errorSessionExpired": "La tua sessione è scaduta. Accedi di nuovo.",
|
||||
"errorValidation": "I dati inseriti non sono validi.",
|
||||
"errorScaRequired": "È richiesta un'autenticazione aggiuntiva per continuare.",
|
||||
"errorDeviceNotOwned": "Non hai il permesso di accedere a questo dispositivo.",
|
||||
"errorGeneric": "Si è verificato un errore imprevisto."
|
||||
}
|
||||
|
||||
@@ -975,5 +975,18 @@
|
||||
"activityMeterNoStepsToday": "Ainda não há passos registados hoje",
|
||||
"activityMeterNoStepsPeriod": "Sem atividade registada neste período",
|
||||
"unitStepsPerDay": "passos/dia",
|
||||
"unitDays": "dias"
|
||||
"unitDays": "dias",
|
||||
"errorOfflineTitle": "Sem ligação",
|
||||
"errorOfflineMessage": "Verifica a tua ligação à internet e tenta novamente.",
|
||||
"errorOfflineRetry": "Tentar novamente",
|
||||
"errorTechnicalTitle": "Algo correu mal",
|
||||
"errorTechnicalMessage": "Estamos a trabalhar para resolver. Tenta novamente mais tarde.",
|
||||
"errorInvalidCredentials": "Utilizador ou palavra-passe incorretos.",
|
||||
"errorNotFound": "Não encontrámos o que procuravas.",
|
||||
"errorRateLimit": "Demasiados pedidos. Espera alguns minutos e tenta novamente.",
|
||||
"errorSessionExpired": "A tua sessão expirou. Inicia sessão novamente.",
|
||||
"errorValidation": "Os dados introduzidos não são válidos.",
|
||||
"errorScaRequired": "É necessária autenticação adicional para continuar.",
|
||||
"errorDeviceNotOwned": "Não tens permissão para aceder a este dispositivo.",
|
||||
"errorGeneric": "Ocorreu um erro inesperado."
|
||||
}
|
||||
|
||||
@@ -337,6 +337,7 @@ class I18n {
|
||||
static const String dontHaveAccount = 'dontHaveAccount';
|
||||
static const String download = 'download';
|
||||
static const String editAlarm = 'editAlarm';
|
||||
static const String editAllowedNumber = 'editAllowedNumber';
|
||||
static const String editChildProfile = 'editChildProfile';
|
||||
static const String editChildProfileSaveSuccess = 'editChildProfileSaveSuccess';
|
||||
static const String editChildProfileTitle = 'editChildProfileTitle';
|
||||
@@ -368,6 +369,7 @@ class I18n {
|
||||
static const String errorContactsMax = 'errorContactsMax';
|
||||
static const String errorContactsMin = 'errorContactsMin';
|
||||
static const String errorDeviceDisconnected = 'errorDeviceDisconnected';
|
||||
static const String errorDeviceNotOwned = 'errorDeviceNotOwned';
|
||||
static const String errorDisableFunctions = 'errorDisableFunctions';
|
||||
static const String errorDocumentNumberRequired = 'errorDocumentNumberRequired';
|
||||
static const String errorDocumentTypeRequired = 'errorDocumentTypeRequired';
|
||||
@@ -387,6 +389,7 @@ class I18n {
|
||||
static const String errorHealthData = 'errorHealthData';
|
||||
static const String errorHealthMeasure = 'errorHealthMeasure';
|
||||
static const String errorHeartRateFrequency = 'errorHeartRateFrequency';
|
||||
static const String errorInvalidCredentials = 'errorInvalidCredentials';
|
||||
static const String errorLanguage = 'errorLanguage';
|
||||
static const String errorLastNameRequired = 'errorLastNameRequired';
|
||||
static const String errorLoadingData = 'errorLoadingData';
|
||||
@@ -401,6 +404,10 @@ class I18n {
|
||||
static const String errorMessagePhoneIsInvalid = 'errorMessagePhoneIsInvalid';
|
||||
static const String errorMessageUnequalPasswords = 'errorMessageUnequalPasswords';
|
||||
static const String errorNameInvalidChars = 'errorNameInvalidChars';
|
||||
static const String errorNotFound = 'errorNotFound';
|
||||
static const String errorOfflineMessage = 'errorOfflineMessage';
|
||||
static const String errorOfflineRetry = 'errorOfflineRetry';
|
||||
static const String errorOfflineTitle = 'errorOfflineTitle';
|
||||
static const String errorPasswordMinLength = 'errorPasswordMinLength';
|
||||
static const String errorPasswordRequired = 'errorPasswordRequired';
|
||||
static const String errorPedometer = 'errorPedometer';
|
||||
@@ -410,20 +417,26 @@ class I18n {
|
||||
static const String errorPlaceOfBirthRequired = 'errorPlaceOfBirthRequired';
|
||||
static const String errorPositionHistory = 'errorPositionHistory';
|
||||
static const String errorPositions = 'errorPositions';
|
||||
static const String errorRateLimit = 'errorRateLimit';
|
||||
static const String errorRefreshPosition = 'errorRefreshPosition';
|
||||
static const String errorRelationshipRequired = 'errorRelationshipRequired';
|
||||
static const String errorScaRequired = 'errorScaRequired';
|
||||
static const String errorScanStrapRequired = 'errorScanStrapRequired';
|
||||
static const String errorScanWatchRequired = 'errorScanWatchRequired';
|
||||
static const String errorSessionExpired = 'errorSessionExpired';
|
||||
static const String errorSigningOperation = 'errorSigningOperation';
|
||||
static const String errorSosContactsMax = 'errorSosContactsMax';
|
||||
static const String errorSoundMode = 'errorSoundMode';
|
||||
static const String errorTakePicture = 'errorTakePicture';
|
||||
static const String errorTechnicalMessage = 'errorTechnicalMessage';
|
||||
static const String errorTechnicalTitle = 'errorTechnicalTitle';
|
||||
static const String errorTimezone = 'errorTimezone';
|
||||
static const String errorTwoFactorCodeInvalid = 'errorTwoFactorCodeInvalid';
|
||||
static const String errorTwoFactorCodeInvalidLength = 'errorTwoFactorCodeInvalidLength';
|
||||
static const String errorTwoFactorCodeRequired = 'errorTwoFactorCodeRequired';
|
||||
static const String errorTwoFactorNoMethods = 'errorTwoFactorNoMethods';
|
||||
static const String errorTwoFactorResendFailed = 'errorTwoFactorResendFailed';
|
||||
static const String errorValidation = 'errorValidation';
|
||||
static const String errorVolumeControl = 'errorVolumeControl';
|
||||
static const String errorWalletConnectFirst = 'errorWalletConnectFirst';
|
||||
static const String errorWalletNotProvisioned = 'errorWalletNotProvisioned';
|
||||
@@ -617,7 +630,6 @@ class I18n {
|
||||
static const String numberAdded = 'numberAdded';
|
||||
static const String numberRemoved = 'numberRemoved';
|
||||
static const String numberUpdated = 'numberUpdated';
|
||||
static const String editAllowedNumber = 'editAllowedNumber';
|
||||
static const String ok = 'ok';
|
||||
static const String onboardingSubtitle1 = 'onboardingSubtitle1';
|
||||
static const String onboardingSubtitle2 = 'onboardingSubtitle2';
|
||||
@@ -827,9 +839,9 @@ class I18n {
|
||||
static const String sosContacts = 'sosContacts';
|
||||
static const String sosContactsCount = 'sosContactsCount';
|
||||
static const String sosDescription = 'sosDescription';
|
||||
static const String sosMinimumOneContact = 'sosMinimumOneContact';
|
||||
static const String sosNumberAdded = 'sosNumberAdded';
|
||||
static const String sosNumberRemoved = 'sosNumberRemoved';
|
||||
static const String sosMinimumOneContact = 'sosMinimumOneContact';
|
||||
static const String sound = 'sound';
|
||||
static const String soundAndVibration = 'soundAndVibration';
|
||||
static const String soundOnly = 'soundOnly';
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
export 'src/data/models/task.dart';
|
||||
export 'src/data/models/savings_goal.dart';
|
||||
export 'src/errors/async_value_ui.dart';
|
||||
export 'src/errors/error_dialogs.dart';
|
||||
export 'src/errors/failure_type.dart';
|
||||
export 'src/errors/feedback_dialogs.dart';
|
||||
export 'src/errors/handle_failure.dart';
|
||||
export 'src/widgets/line_graph.dart';
|
||||
export 'src/widgets/deposit_block.dart';
|
||||
export 'src/screens/connection_error_screen.dart';
|
||||
|
||||
23
packages/sf_shared/lib/src/errors/async_value_ui.dart
Normal file
23
packages/sf_shared/lib/src/errors/async_value_ui.dart
Normal file
@@ -0,0 +1,23 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'failure_type.dart';
|
||||
import 'handle_failure.dart';
|
||||
|
||||
extension AsyncValueUI<T> on AsyncValue<T> {
|
||||
Future<void> showErrorOn(
|
||||
BuildContext context, {
|
||||
VoidCallback? onRetry,
|
||||
VoidCallback? onSessionExpired,
|
||||
VoidCallback? onScaRequired,
|
||||
}) async {
|
||||
if (isLoading || !hasError) return;
|
||||
await handleFailure(
|
||||
context: context,
|
||||
failureType: FailureType.fromException(error),
|
||||
onRetry: onRetry,
|
||||
onSessionExpired: onSessionExpired,
|
||||
onScaRequired: onScaRequired,
|
||||
);
|
||||
}
|
||||
}
|
||||
40
packages/sf_shared/lib/src/errors/error_dialogs.dart
Normal file
40
packages/sf_shared/lib/src/errors/error_dialogs.dart
Normal file
@@ -0,0 +1,40 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:sf_localizations/sf_localizations.dart';
|
||||
|
||||
Future<void> showOfflineDialog(
|
||||
BuildContext context, {
|
||||
VoidCallback? onRetry,
|
||||
}) {
|
||||
return showDialog<void>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(ctx.translate(I18n.errorOfflineTitle)),
|
||||
content: Text(ctx.translate(I18n.errorOfflineMessage)),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(ctx).pop();
|
||||
onRetry?.call();
|
||||
},
|
||||
child: Text(ctx.translate(I18n.errorOfflineRetry)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> showTechnicalErrorDialog(BuildContext context) {
|
||||
return showDialog<void>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(ctx.translate(I18n.errorTechnicalTitle)),
|
||||
content: Text(ctx.translate(I18n.errorTechnicalMessage)),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(),
|
||||
child: Text(ctx.translate(I18n.accept)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
47
packages/sf_shared/lib/src/errors/failure_type.dart
Normal file
47
packages/sf_shared/lib/src/errors/failure_type.dart
Normal file
@@ -0,0 +1,47 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:sf_infrastructure/sf_infrastructure.dart';
|
||||
|
||||
enum FailureType {
|
||||
connection,
|
||||
technical,
|
||||
notAuthorized,
|
||||
invalidCredentials,
|
||||
validation,
|
||||
notFound,
|
||||
rateLimit,
|
||||
sessionExpired,
|
||||
scaRequired,
|
||||
deviceNotOwned,
|
||||
deviceUnavailable,
|
||||
other;
|
||||
|
||||
static FailureType fromException(Object? exception) {
|
||||
if (exception is ApiException) {
|
||||
if (exception.isNetworkError) return FailureType.connection;
|
||||
final status = exception.statusCode;
|
||||
if (status == null) return FailureType.other;
|
||||
return switch (status) {
|
||||
401 => FailureType.notAuthorized,
|
||||
403 => FailureType.notAuthorized,
|
||||
404 => FailureType.notFound,
|
||||
422 => FailureType.validation,
|
||||
429 => FailureType.rateLimit,
|
||||
>= 500 => FailureType.technical,
|
||||
_ => FailureType.other,
|
||||
};
|
||||
}
|
||||
|
||||
if (exception is DioException) {
|
||||
return switch (exception.type) {
|
||||
DioExceptionType.connectionTimeout ||
|
||||
DioExceptionType.sendTimeout ||
|
||||
DioExceptionType.receiveTimeout ||
|
||||
DioExceptionType.connectionError =>
|
||||
FailureType.connection,
|
||||
_ => FailureType.other,
|
||||
};
|
||||
}
|
||||
|
||||
return FailureType.other;
|
||||
}
|
||||
}
|
||||
93
packages/sf_shared/lib/src/errors/feedback_dialogs.dart
Normal file
93
packages/sf_shared/lib/src/errors/feedback_dialogs.dart
Normal file
@@ -0,0 +1,93 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:sf_localizations/sf_localizations.dart';
|
||||
|
||||
enum _FeedbackKind { error, success, info }
|
||||
|
||||
Future<void> showErrorDialog(
|
||||
BuildContext context,
|
||||
String messageKey, {
|
||||
Map<String, dynamic>? args,
|
||||
}) {
|
||||
return _showFeedbackDialog(
|
||||
context,
|
||||
messageKey: messageKey,
|
||||
args: args,
|
||||
kind: _FeedbackKind.error,
|
||||
autoDismiss: null,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> showSuccessDialog(
|
||||
BuildContext context,
|
||||
String messageKey, {
|
||||
Map<String, dynamic>? args,
|
||||
Duration autoDismiss = const Duration(seconds: 2),
|
||||
}) {
|
||||
return _showFeedbackDialog(
|
||||
context,
|
||||
messageKey: messageKey,
|
||||
args: args,
|
||||
kind: _FeedbackKind.success,
|
||||
autoDismiss: autoDismiss,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> showInfoDialog(
|
||||
BuildContext context,
|
||||
String messageKey, {
|
||||
Map<String, dynamic>? args,
|
||||
Duration autoDismiss = const Duration(seconds: 2),
|
||||
}) {
|
||||
return _showFeedbackDialog(
|
||||
context,
|
||||
messageKey: messageKey,
|
||||
args: args,
|
||||
kind: _FeedbackKind.info,
|
||||
autoDismiss: autoDismiss,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showFeedbackDialog(
|
||||
BuildContext context, {
|
||||
required String messageKey,
|
||||
required Map<String, dynamic>? args,
|
||||
required _FeedbackKind kind,
|
||||
required Duration? autoDismiss,
|
||||
}) async {
|
||||
Timer? timer;
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: autoDismiss != null,
|
||||
builder: (ctx) {
|
||||
if (autoDismiss != null) {
|
||||
timer = Timer(autoDismiss, () {
|
||||
if (Navigator.of(ctx).canPop()) {
|
||||
Navigator.of(ctx).pop();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
final resolved = ctx.translate(messageKey, args: args);
|
||||
final theme = Theme.of(ctx);
|
||||
final (icon, color) = switch (kind) {
|
||||
_FeedbackKind.error => (Icons.error_outline, theme.colorScheme.error),
|
||||
_FeedbackKind.success => (Icons.check_circle_outline, Colors.green),
|
||||
_FeedbackKind.info => (Icons.info_outline, theme.colorScheme.primary),
|
||||
};
|
||||
|
||||
return AlertDialog(
|
||||
icon: Icon(icon, color: color, size: 48),
|
||||
content: Text(resolved, textAlign: TextAlign.center),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(),
|
||||
child: Text(ctx.translate(I18n.accept)),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
timer?.cancel();
|
||||
}
|
||||
59
packages/sf_shared/lib/src/errors/handle_failure.dart
Normal file
59
packages/sf_shared/lib/src/errors/handle_failure.dart
Normal file
@@ -0,0 +1,59 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:sf_localizations/sf_localizations.dart';
|
||||
|
||||
import 'error_dialogs.dart';
|
||||
import 'failure_type.dart';
|
||||
import 'feedback_dialogs.dart';
|
||||
|
||||
// Prevents stacking multiple error dialogs when several failures fire quickly.
|
||||
bool _dialogOpen = false;
|
||||
|
||||
@visibleForTesting
|
||||
void resetDialogOpenFlag() {
|
||||
_dialogOpen = false;
|
||||
}
|
||||
|
||||
Future<void> handleFailure({
|
||||
required BuildContext context,
|
||||
required FailureType failureType,
|
||||
VoidCallback? onRetry,
|
||||
VoidCallback? onSessionExpired,
|
||||
VoidCallback? onScaRequired,
|
||||
}) async {
|
||||
if (_dialogOpen) return;
|
||||
_dialogOpen = true;
|
||||
try {
|
||||
switch (failureType) {
|
||||
case FailureType.connection:
|
||||
await showOfflineDialog(context, onRetry: onRetry);
|
||||
case FailureType.technical || FailureType.other:
|
||||
await showTechnicalErrorDialog(context);
|
||||
case FailureType.notAuthorized || FailureType.invalidCredentials:
|
||||
await showErrorDialog(context, I18n.errorInvalidCredentials);
|
||||
case FailureType.notFound:
|
||||
await showErrorDialog(context, I18n.errorNotFound);
|
||||
case FailureType.validation:
|
||||
await showErrorDialog(context, I18n.errorValidation);
|
||||
case FailureType.rateLimit:
|
||||
await showErrorDialog(context, I18n.errorRateLimit);
|
||||
case FailureType.sessionExpired:
|
||||
if (onSessionExpired != null) {
|
||||
onSessionExpired();
|
||||
} else {
|
||||
await showErrorDialog(context, I18n.errorSessionExpired);
|
||||
}
|
||||
case FailureType.scaRequired:
|
||||
if (onScaRequired != null) {
|
||||
onScaRequired();
|
||||
} else {
|
||||
await showTechnicalErrorDialog(context);
|
||||
}
|
||||
case FailureType.deviceNotOwned:
|
||||
await showErrorDialog(context, I18n.errorDeviceNotOwned);
|
||||
case FailureType.deviceUnavailable:
|
||||
await showErrorDialog(context, I18n.errorDeviceDisconnected);
|
||||
}
|
||||
} finally {
|
||||
_dialogOpen = false;
|
||||
}
|
||||
}
|
||||
92
packages/sf_shared/test/errors/failure_type_test.dart
Normal file
92
packages/sf_shared/test/errors/failure_type_test.dart
Normal file
@@ -0,0 +1,92 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:sf_infrastructure/sf_infrastructure.dart';
|
||||
import 'package:sf_shared/sf_shared.dart';
|
||||
|
||||
void main() {
|
||||
group('FailureType.fromException', () {
|
||||
test('returns connection for ApiException(isNetworkError: true)', () {
|
||||
final e = const ApiException(message: 'm', isNetworkError: true);
|
||||
expect(FailureType.fromException(e), FailureType.connection);
|
||||
});
|
||||
|
||||
test('maps ApiException 401 to notAuthorized', () {
|
||||
final e = const ApiException(message: 'm', statusCode: 401);
|
||||
expect(FailureType.fromException(e), FailureType.notAuthorized);
|
||||
});
|
||||
|
||||
test('maps ApiException 403 to notAuthorized', () {
|
||||
final e = const ApiException(message: 'm', statusCode: 403);
|
||||
expect(FailureType.fromException(e), FailureType.notAuthorized);
|
||||
});
|
||||
|
||||
test('maps ApiException 404 to notFound', () {
|
||||
final e = const ApiException(message: 'm', statusCode: 404);
|
||||
expect(FailureType.fromException(e), FailureType.notFound);
|
||||
});
|
||||
|
||||
test('maps ApiException 422 to validation', () {
|
||||
final e = const ApiException(message: 'm', statusCode: 422);
|
||||
expect(FailureType.fromException(e), FailureType.validation);
|
||||
});
|
||||
|
||||
test('maps ApiException 429 to rateLimit', () {
|
||||
final e = const ApiException(message: 'm', statusCode: 429);
|
||||
expect(FailureType.fromException(e), FailureType.rateLimit);
|
||||
});
|
||||
|
||||
test('maps ApiException 500+ to technical', () {
|
||||
expect(
|
||||
FailureType.fromException(
|
||||
const ApiException(message: 'm', statusCode: 500),
|
||||
),
|
||||
FailureType.technical,
|
||||
);
|
||||
expect(
|
||||
FailureType.fromException(
|
||||
const ApiException(message: 'm', statusCode: 503),
|
||||
),
|
||||
FailureType.technical,
|
||||
);
|
||||
});
|
||||
|
||||
test('maps ApiException with unknown status to other', () {
|
||||
final e = const ApiException(message: 'm', statusCode: 418);
|
||||
expect(FailureType.fromException(e), FailureType.other);
|
||||
});
|
||||
|
||||
test('returns other for ApiException without statusCode', () {
|
||||
const e = ApiException(message: 'm');
|
||||
expect(FailureType.fromException(e), FailureType.other);
|
||||
});
|
||||
|
||||
test('maps Dio timeout types to connection', () {
|
||||
final req = RequestOptions(path: '/x');
|
||||
for (final type in [
|
||||
DioExceptionType.connectionTimeout,
|
||||
DioExceptionType.sendTimeout,
|
||||
DioExceptionType.receiveTimeout,
|
||||
DioExceptionType.connectionError,
|
||||
]) {
|
||||
final e = DioException(requestOptions: req, type: type);
|
||||
expect(
|
||||
FailureType.fromException(e),
|
||||
FailureType.connection,
|
||||
reason: 'type=$type',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('maps other DioException types to other', () {
|
||||
final req = RequestOptions(path: '/x');
|
||||
final e = DioException(requestOptions: req, type: DioExceptionType.cancel);
|
||||
expect(FailureType.fromException(e), FailureType.other);
|
||||
});
|
||||
|
||||
test('returns other for arbitrary exceptions', () {
|
||||
expect(FailureType.fromException(Exception('boom')), FailureType.other);
|
||||
expect(FailureType.fromException(null), FailureType.other);
|
||||
expect(FailureType.fromException('string'), FailureType.other);
|
||||
});
|
||||
});
|
||||
}
|
||||
36
packages/sf_shared/test/errors/feedback_dialogs_test.dart
Normal file
36
packages/sf_shared/test/errors/feedback_dialogs_test.dart
Normal file
@@ -0,0 +1,36 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:sf_localizations/sf_localizations.dart';
|
||||
import 'package:sf_shared/sf_shared.dart';
|
||||
import 'package:sf_shared/testing.dart';
|
||||
|
||||
// Only the error variant is smoke-tested. success/info rely on Timer + Navigator.pop
|
||||
// and hit cross-test SFLocalizations state, so they're covered by feature integration
|
||||
// tests (Fase 1+) instead.
|
||||
void main() {
|
||||
testWidgets('showErrorDialog displays AlertDialog with an accept button', (tester) async {
|
||||
await pumpApp(
|
||||
tester,
|
||||
withLocalizations: true,
|
||||
child: Builder(
|
||||
builder: (ctx) => ElevatedButton(
|
||||
onPressed: () {},
|
||||
child: const Text('go'),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
final ctx = tester.element(find.text('go'));
|
||||
final future = showErrorDialog(ctx, I18n.errorInvalidCredentials);
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byType(AlertDialog), findsOneWidget);
|
||||
expect(find.byType(TextButton), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byType(TextButton));
|
||||
await tester.pump();
|
||||
await future;
|
||||
});
|
||||
}
|
||||
64
packages/sf_shared/test/errors/handle_failure_test.dart
Normal file
64
packages/sf_shared/test/errors/handle_failure_test.dart
Normal file
@@ -0,0 +1,64 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:sf_shared/sf_shared.dart';
|
||||
|
||||
void main() {
|
||||
setUp(resetDialogOpenFlag);
|
||||
|
||||
Future<void> pumpHost(
|
||||
WidgetTester tester, {
|
||||
required void Function(BuildContext) onPressed,
|
||||
}) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: Builder(
|
||||
builder: (context) => ElevatedButton(
|
||||
onPressed: () => onPressed(context),
|
||||
child: const Text('go'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
testWidgets(
|
||||
'sessionExpired triggers onSessionExpired when provided',
|
||||
(tester) async {
|
||||
var called = false;
|
||||
await pumpHost(
|
||||
tester,
|
||||
onPressed: (ctx) => handleFailure(
|
||||
context: ctx,
|
||||
failureType: FailureType.sessionExpired,
|
||||
onSessionExpired: () => called = true,
|
||||
),
|
||||
);
|
||||
await tester.tap(find.text('go'));
|
||||
await tester.pump();
|
||||
|
||||
expect(called, isTrue);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'scaRequired triggers onScaRequired when provided',
|
||||
(tester) async {
|
||||
var called = false;
|
||||
await pumpHost(
|
||||
tester,
|
||||
onPressed: (ctx) => handleFailure(
|
||||
context: ctx,
|
||||
failureType: FailureType.scaRequired,
|
||||
onScaRequired: () => called = true,
|
||||
),
|
||||
);
|
||||
await tester.tap(find.text('go'));
|
||||
await tester.pump();
|
||||
|
||||
expect(called, isTrue);
|
||||
},
|
||||
);
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user