diff --git a/core/lib/presentation/state/failure.dart b/core/lib/presentation/state/failure.dart index 9ea7d6a70..e1f130619 100644 --- a/core/lib/presentation/state/failure.dart +++ b/core/lib/presentation/state/failure.dart @@ -1,3 +1,5 @@ +import 'package:core/presentation/state/success.dart'; +import 'package:dartz/dartz.dart'; import 'package:equatable/equatable.dart'; abstract class Failure with EquatableMixin { @@ -8,9 +10,10 @@ abstract class Failure with EquatableMixin { abstract class FeatureFailure extends Failure { final dynamic exception; + final Stream>? onRetry; - FeatureFailure({this.exception}); + FeatureFailure({this.exception, this.onRetry}); @override - List get props => [exception]; + List get props => [exception, onRetry]; } diff --git a/core/lib/presentation/utils/app_toast.dart b/core/lib/presentation/utils/app_toast.dart index b1bbf0aa5..76e85aaab 100644 --- a/core/lib/presentation/utils/app_toast.dart +++ b/core/lib/presentation/utils/app_toast.dart @@ -184,4 +184,122 @@ class AppToast { : (duration ?? const Duration(seconds: 3)), ); } + + void showToastMessageWithMultipleActions( + BuildContext context, + String message, + { + List<({ + String? actionName, + Function? onActionClick, + Widget? actionIcon + })> actions = const [], + Widget? leadingIcon, + String? leadingSVGIcon, + Color? leadingSVGIconColor, + double? maxWidth, + bool infinityToast = false, + Color? backgroundColor, + Color? textColor, + Color? textActionColor, + TextStyle? textStyle, + EdgeInsets? padding, + TextAlign? textAlign, + Duration? duration, + } + ) { + final responsiveUtils = Get.find(); + List trailingWidgets = []; + for (var action in actions) { + if (action.actionName == null) continue; + + if (action.actionIcon == null) { + trailingWidgets.add(PointerInterceptor( + child: TextButton( + onPressed: () { + ToastView.dismiss(); + action.onActionClick?.call(); + }, + child: Text( + action.actionName!, + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.normal, + color: textActionColor ?? Colors.white + ), + ), + ), + )); + } else { + trailingWidgets.add(PointerInterceptor( + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () { + ToastView.dismiss(); + action.onActionClick?.call(); + }, + customBorder: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(5), + ), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 8), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + action.actionIcon!, + Text( + action.actionName!, + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.normal, + color: textActionColor ?? Colors.white + ), + ) + ], + ), + ), + ), + ), + )); + } + } + + Widget? leadingWidget; + if (leadingIcon != null) { + leadingWidget = PointerInterceptor(child: leadingIcon); + } else { + if (leadingSVGIcon != null) { + leadingWidget = PointerInterceptor( + child: SvgPicture.asset( + leadingSVGIcon, + width: 24, + height: 24, + fit: BoxFit.fill, + colorFilter: leadingSVGIconColor?.asFilter(), + ) + ); + } + } + + TMailToast.showToast( + message, + context, + maxWidth: maxWidth ?? responsiveUtils.getMaxWidthToast(context), + toastPosition: ToastPosition.BOTTOM, + textStyle: textStyle ?? TextStyle( + fontSize: 15, + fontWeight: FontWeight.normal, + color: textColor ?? AppColor.primaryColor + ), + backgroundColor: backgroundColor ?? Colors.white, + trailing: Row(children: trailingWidgets), + leading: leadingWidget, + padding: padding, + textAlign: textAlign, + toastDuration: infinityToast + ? null + : (duration ?? const Duration(seconds: 3)), + ); + } } diff --git a/core/test/presentation/utils/app_toast_test.dart b/core/test/presentation/utils/app_toast_test.dart new file mode 100644 index 000000000..1115205a8 --- /dev/null +++ b/core/test/presentation/utils/app_toast_test.dart @@ -0,0 +1,76 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get/get.dart'; +import 'package:mockito/mockito.dart'; +import 'package:core/presentation/utils/app_toast.dart'; +import 'package:core/presentation/utils/responsive_utils.dart'; + +class MockResponsiveUtils extends Mock implements ResponsiveUtils { + @override + double getMaxWidthToast(BuildContext context) => 300; +} + +void main() { + group('AppToast - showToastMessageWithMultipleActions', () { + late AppToast appToast; + late MockResponsiveUtils mockResponsiveUtils; + + setUp(() { + appToast = AppToast(); + mockResponsiveUtils = MockResponsiveUtils(); + Get.put(mockResponsiveUtils); + }); + + tearDown(() { + Get.reset(); + }); + + testWidgets( + 'should show action button ' + 'when actionName is provided and trigger callback when tapped', + (WidgetTester tester) async { + await tester.runAsync(() async { + var callbackTriggered = false; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) { + return TextButton( + onPressed: () { + appToast.showToastMessageWithMultipleActions( + context, + 'Test message', + actions: [ + ( + actionName: 'Retry', + onActionClick: () { + callbackTriggered = true; + }, + actionIcon: null, + ), + ], + ); + }, + child: const Text('Show toast'), + ); + }, + ), + ), + ), + ); + + await tester.tap(find.text('Show toast')); + await tester.pump(); + + expect(find.text('Retry'), findsOneWidget); + + await tester.tap(find.text('Retry')); + await tester.pump(); + + expect(callbackTriggered, true); + }); + }); + }); +} \ No newline at end of file diff --git a/lib/features/base/base_controller.dart b/lib/features/base/base_controller.dart index 78e93c64b..cc6866edf 100644 --- a/lib/features/base/base_controller.dart +++ b/lib/features/base/base_controller.dart @@ -617,4 +617,37 @@ abstract class BaseController extends GetxController final minInputLength = session.getMinInputLengthAutocomplete(accountId); return minInputLength?.value.toInt() ?? AppConfig.defaultMinInputLengthAutocomplete; } + + void showRetryToast(FeatureFailure failure) { + if (currentOverlayContext == null || currentContext == null) return; + + final exception = failure.exception; + final errorMessage = exception is MethodLevelErrors && exception.message != null + ? AppLocalizations.of(currentContext!).unexpectedError('${exception.message!}') + : AppLocalizations.of(currentContext!).unknownError; + + appToast.showToastMessageWithMultipleActions( + currentOverlayContext!, + errorMessage, + actions: [ + if (failure.onRetry != null) + ( + actionName: AppLocalizations.of(currentContext!).retry, + onActionClick: () => consumeState(failure.onRetry!), + actionIcon: SvgPicture.asset(imagePaths.icUndo), + ), + ( + actionName: AppLocalizations.of(currentContext!).close, + onActionClick: () => ToastView.dismiss(), + actionIcon: SvgPicture.asset( + imagePaths.icClose, + colorFilter: Colors.white.asFilter(), + ), + ) + ], + backgroundColor: AppColor.toastErrorBackgroundColor, + textColor: Colors.white, + infinityToast: true, + ); + } } diff --git a/lib/l10n/intl_messages.arb b/lib/l10n/intl_messages.arb index dfc59c144..f091d42c0 100644 --- a/lib/l10n/intl_messages.arb +++ b/lib/l10n/intl_messages.arb @@ -4393,5 +4393,11 @@ "type": "text", "placeholders_order": [], "placeholders": {} + }, + "retry": "Retry", + "@retry": { + "type": "text", + "placeholders_order": [], + "placeholders": {} } } \ No newline at end of file diff --git a/lib/main/localizations/app_localizations.dart b/lib/main/localizations/app_localizations.dart index 12b91dfdf..9f7ab9626 100644 --- a/lib/main/localizations/app_localizations.dart +++ b/lib/main/localizations/app_localizations.dart @@ -4618,4 +4618,11 @@ class AppLocalizations { name: 'viewEntireMessage', ); } + + String get retry { + return Intl.message( + 'Retry', + name: 'retry', + ); + } }