diff --git a/lib/features/chat/presentation/widgets/notification_banner.dart b/lib/features/chat/presentation/widgets/notification_banner.dart index 59650d6c..11db4288 100644 --- a/lib/features/chat/presentation/widgets/notification_banner.dart +++ b/lib/features/chat/presentation/widgets/notification_banner.dart @@ -9,7 +9,7 @@ import 'package:eatvio_app/features/chat/presentation/widgets/complete_plan_bott import 'package:eatvio_app/features/chat/presentation/widgets/plan_complain_bottom_sheet.dart'; import 'package:eatvio_app/features/my_diets/presentation/my_diets_screen.dart'; import 'package:eatvio_app/features/my_finance/presentation/pages/card_settings.dart'; -import 'package:eatvio_app/features/payment/presentation/widgets/sbp_banks_bottom_sheet.dart'; +import 'package:eatvio_app/features/payment/presentation/payment_flow.dart'; import 'package:eatvio_app/generated/l10n.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -106,7 +106,7 @@ class _NotificationBannerState extends State { onTap: () { final planId = (context.read().state as ChatRoomStateLoaded).chatRoom.chosenPlan?.id; if (planId != null) { - showSbpBanksBottomSheet(context, planId: planId); + showConsultationPaymentFlow(context, planId: planId); } }, child: Text(s.pay, style: TextStyles.textLSemibold.copyWith(color: appTheme.blueA400)), diff --git a/lib/features/payment/presentation/payment_flow.dart b/lib/features/payment/presentation/payment_flow.dart new file mode 100644 index 00000000..03cfe5b5 --- /dev/null +++ b/lib/features/payment/presentation/payment_flow.dart @@ -0,0 +1,49 @@ +import 'package:eatvio_app/features/payment/presentation/widgets/promo_code_gate_bottom_sheet.dart'; +import 'package:eatvio_app/features/payment/presentation/widgets/promo_code_modal.dart'; +import 'package:eatvio_app/features/payment/presentation/widgets/sbp_banks_bottom_sheet.dart'; +import 'package:eatvio_app/generated/l10n.dart'; +import 'package:flutter/material.dart'; + +/// Оркестрирует флоу оплаты консультации: +/// гейт «У вас есть промокод?» → (Да → форма промокода) → SbpBanks. +Future showConsultationPaymentFlow( + BuildContext context, { + required int planId, + Future Function(BuildContext)? showGate, + Future Function(BuildContext, {Future Function(String)? onValidate})? showForm, + Future Function(BuildContext, {required int planId})? showSbp, +}) async { + final gate = showGate ?? _showGate; + final form = showForm ?? _showForm; + final sbp = showSbp ?? _showSbp; + final s = S.of(context); + + final gateChoice = await gate(context); + if (gateChoice == null || !context.mounted) return; + + if (gateChoice) { + final formResult = await form( + context, + onValidate: (_) async => PromoValidation.failure(s.promo_invalid), + ); + if (formResult == null || !context.mounted) return; + } + + if (!context.mounted) return; + await sbp(context, planId: planId); +} + +Future _showGate(BuildContext context) { + return showPromoCodeGateBottomSheet(context); +} + +Future _showForm( + BuildContext context, { + Future Function(String)? onValidate, +}) { + return ItvioPromoCodeModal.show(context, onValidate: onValidate); +} + +Future _showSbp(BuildContext context, {required int planId}) { + return showSbpBanksBottomSheet(context, planId: planId); +} diff --git a/lib/features/payment/presentation/widgets/promo_code_gate_bottom_sheet.dart b/lib/features/payment/presentation/widgets/promo_code_gate_bottom_sheet.dart new file mode 100644 index 00000000..b72a3f13 --- /dev/null +++ b/lib/features/payment/presentation/widgets/promo_code_gate_bottom_sheet.dart @@ -0,0 +1,97 @@ +import 'package:eatvio_app/core/theme/custom_text_style.dart'; +import 'package:eatvio_app/core/theme/theme_helper.dart'; +import 'package:eatvio_app/core/widgets/bottom_sheet_handler_bar.dart'; +import 'package:eatvio_app/generated/l10n.dart'; +import 'package:flutter/material.dart'; + +/// Показывает bottom sheet «У вас есть промокод?» с кнопками Да/Нет. +/// +/// Возвращает `true` если пользователь нажал «Да», `false` если «Нет», +/// `null` если закрыл шторку (бекдроп/крестик). +Future showPromoCodeGateBottomSheet(BuildContext context) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + barrierColor: appTheme.transparentBlack, + backgroundColor: Colors.transparent, + elevation: 0, + useSafeArea: true, + builder: (_) => Container( + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + child: _PromoCodeGateSheet(), + ), + ); +} + +/// Виджет-гейт выбора промокода. +class PromoCodeGateBottomSheet extends StatelessWidget { + const PromoCodeGateBottomSheet({super.key, required this.onResult}); + + final ValueChanged onResult; + + @override + Widget build(BuildContext context) { + final s = S.of(context); + return Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 20), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Center(child: BottomSheetHandlerBar()), + const SizedBox(height: 24), + Text( + s.have_promo_code, + style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black), + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + SizedBox( + height: 50, + child: FilledButton( + onPressed: () => onResult(true), + style: FilledButton.styleFrom( + backgroundColor: appTheme.blueA400, + foregroundColor: appTheme.whiteA700, + shape: const StadiumBorder(), + ), + child: Text( + s.yes, + style: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700), + ), + ), + ), + const SizedBox(height: 8), + SizedBox( + height: 50, + child: OutlinedButton( + onPressed: () => onResult(false), + style: OutlinedButton.styleFrom( + foregroundColor: appTheme.blueA400, + side: BorderSide(color: appTheme.blueA400), + shape: const StadiumBorder(), + ), + child: Text( + s.no, + style: TextStyles.textMSemibold.copyWith(color: appTheme.blueA400), + ), + ), + ), + ], + ), + ); + } +} + +/// Внутренняя реализация модалки — обёртка над [PromoCodeGateBottomSheet]. +class _PromoCodeGateSheet extends StatelessWidget { + @override + Widget build(BuildContext context) { + return PromoCodeGateBottomSheet( + onResult: (result) => Navigator.of(context).pop(result), + ); + } +} diff --git a/lib/features/payment/presentation/widgets/promo_code_modal.dart b/lib/features/payment/presentation/widgets/promo_code_modal.dart new file mode 100644 index 00000000..0d4a9942 --- /dev/null +++ b/lib/features/payment/presentation/widgets/promo_code_modal.dart @@ -0,0 +1,281 @@ +import 'package:eatvio_app/core/theme/custom_text_style.dart'; +import 'package:eatvio_app/core/theme/theme_helper.dart'; +import 'package:eatvio_app/core/widgets/bottom_sheet_handler_bar.dart'; +import 'package:eatvio_app/generated/l10n.dart'; +import 'package:flutter/material.dart'; + +/// Результат работы модалки промокода. +class PromoCodeResult { + const PromoCodeResult({this.code}); + + /// Применённый промокод или null, если пользователь продолжил без него. + final String? code; + + bool get hasPromoCode => code != null; +} + +/// Ответ проверки промокода. +class PromoValidation { + const PromoValidation.success() : error = null; + const PromoValidation.failure(this.error); + + final String? error; + + bool get isValid => error == null; +} + +/// Модальное окно ввода промокода. +class ItvioPromoCodeModal extends StatefulWidget { + const ItvioPromoCodeModal({super.key, this.onValidate}); + + final Future Function(String code)? onValidate; + + static Future show( + BuildContext context, { + Future Function(String code)? onValidate, + }) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + backgroundColor: Colors.transparent, + builder: (_) => ItvioPromoCodeModal(onValidate: onValidate), + ); + } + + @override + State createState() => _ItvioPromoCodeModalState(); +} + +enum _ModalState { input, loading } + +class _ItvioPromoCodeModalState extends State { + final _controller = TextEditingController(); + final _focusNode = FocusNode(); + + _ModalState _state = _ModalState.input; + String? _errorText; + + @override + void dispose() { + _controller.dispose(); + _focusNode.dispose(); + super.dispose(); + } + + Future _apply() async { + final code = _controller.text.trim().toUpperCase(); + + if (code.isEmpty) { + setState(() => _errorText = S.of(context).enter_promo_code); + return; + } + + setState(() { + _state = _ModalState.loading; + _errorText = null; + }); + + PromoValidation validation; + try { + validation = widget.onValidate != null + ? await widget.onValidate!(code) + : const PromoValidation.success(); + } catch (_) { + validation = const PromoValidation.failure( + 'Не удалось проверить промокод. Попробуйте позже', + ); + } + + if (!mounted) return; + + if (!validation.isValid) { + setState(() { + _state = _ModalState.input; + _errorText = validation.error; + }); + _focusNode.requestFocus(); + return; + } + + Navigator.of(context).pop(PromoCodeResult(code: code)); + } + + void _onSkip() { + Navigator.of(context).pop(const PromoCodeResult()); + } + + bool get _isLoading => _state == _ModalState.loading; + + @override + Widget build(BuildContext context) { + final bottomInset = MediaQuery.of(context).viewInsets.bottom; + final s = S.of(context); + + return Padding( + padding: EdgeInsets.only(bottom: bottomInset), + child: Container( + width: double.infinity, + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + padding: const EdgeInsets.fromLTRB(20, 8, 20, 20), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Center(child: BottomSheetHandlerBar()), + const SizedBox(height: 16), + _buildHeader(), + const SizedBox(height: 16), + const Text( + 'Промокод', + style: TextStyle( + fontSize: 13, + color: Color(0xFF6B7684), + ), + ), + const SizedBox(height: 8), + _buildField(), + const SizedBox(height: 8), + _buildHint(), + const SizedBox(height: 12), + _buildApplyButton(s), + const SizedBox(height: 6), + _buildSkipButton(s), + ], + ), + ), + ); + } + + Widget _buildHeader() { + final s = S.of(context); + return Text( + s.have_promo_code_form, + style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black), + ); + } + + Widget _buildField() { + final hasError = _errorText != null; + final s = S.of(context); + + return AnimatedContainer( + duration: const Duration(milliseconds: 150), + decoration: BoxDecoration( + color: appTheme.gray100, + borderRadius: BorderRadius.circular(12), + border: Border.all( + width: 2, + color: hasError + ? appTheme.red + : (_focusNode.hasFocus + ? appTheme.blueA400 + : Colors.transparent), + ), + ), + child: Row( + children: [ + Expanded( + child: TextField( + controller: _controller, + focusNode: _focusNode, + enabled: !_isLoading, + textCapitalization: TextCapitalization.characters, + onChanged: (_) { + if (_errorText != null) { + setState(() => _errorText = null); + } + }, + onSubmitted: (_) => _apply(), + decoration: InputDecoration( + hintText: s.enter_promo_code, + hintStyle: const TextStyle( + color: Color(0xFF9AA3AF), + ), + border: InputBorder.none, + isDense: true, + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 14, + ), + ), + style: const TextStyle( + fontSize: 15, + color: Color(0xFF24303F), + ), + ), + ), + ], + ), + ); + } + + Widget _buildHint() { + final String text; + final Color color; + + if (_errorText != null) { + text = _errorText!; + color = appTheme.red; + } else { + text = ''; + color = Colors.transparent; + } + + return SizedBox( + height: 19, + child: Text( + text, + style: TextStyle(fontSize: 13, color: color), + overflow: TextOverflow.ellipsis, + ), + ); + } + + Widget _buildApplyButton(S s) { + return SizedBox( + height: 50, + child: FilledButton( + onPressed: _isLoading ? null : _apply, + style: FilledButton.styleFrom( + backgroundColor: appTheme.blueA400, + foregroundColor: appTheme.whiteA700, + disabledBackgroundColor: appTheme.blueA400.withValues(alpha: 0.55), + shape: const StadiumBorder(), + textStyle: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + child: _isLoading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2.5, + color: Colors.white, + ), + ) + : Text(s.apply), + ), + ); + } + + Widget _buildSkipButton(S s) { + return SizedBox( + height: 46, + child: TextButton( + onPressed: _isLoading ? null : _onSkip, + style: TextButton.styleFrom( + foregroundColor: appTheme.blueA400, + shape: const StadiumBorder(), + textStyle: const TextStyle(fontSize: 14), + ), + child: Text(s.skip), + ), + ); + } +} diff --git a/lib/generated/intl/messages_en.dart b/lib/generated/intl/messages_en.dart index d79fe75d..58eb63e6 100644 --- a/lib/generated/intl/messages_en.dart +++ b/lib/generated/intl/messages_en.dart @@ -123,6 +123,7 @@ class MessageLookup extends MessageLookupByLibrary { "another_form_of_minute": MessageLookupByLibrary.simpleMessage("минут"), "another_form_of_stage": MessageLookupByLibrary.simpleMessage("этапа"), "answer": MessageLookupByLibrary.simpleMessage("Ответить"), + "apply": MessageLookupByLibrary.simpleMessage("Apply"), "archived": MessageLookupByLibrary.simpleMessage("В архиве"), "atLeast3Symbols": MessageLookupByLibrary.simpleMessage( "Минимум 3 символа", @@ -381,6 +382,9 @@ class MessageLookup extends MessageLookupByLibrary { "enter_information": MessageLookupByLibrary.simpleMessage( "Введите информацию...", ), + "enter_promo_code": MessageLookupByLibrary.simpleMessage( + "Enter promo code", + ), "error_desc": MessageLookupByLibrary.simpleMessage( "Произошла ошибка. Мы уже работаем над её устранением. Попробуйте позже", ), @@ -466,6 +470,12 @@ class MessageLookup extends MessageLookupByLibrary { "hatred_description": MessageLookupByLibrary.simpleMessage( "Враждебные высказывания", ), + "have_promo_code": MessageLookupByLibrary.simpleMessage( + "Do you have a promo code?", + ), + "have_promo_code_form": MessageLookupByLibrary.simpleMessage( + "Have a promo code?", + ), "height": MessageLookupByLibrary.simpleMessage("Рост"), "height_error": MessageLookupByLibrary.simpleMessage("Введите рост"), "height_measurements": MessageLookupByLibrary.simpleMessage("Замер роста"), @@ -705,6 +715,10 @@ class MessageLookup extends MessageLookupByLibrary { "Разница в ценах, это комиссия Итвио", ), "profile": MessageLookupByLibrary.simpleMessage("Профиль"), + "promo_code": MessageLookupByLibrary.simpleMessage("Promo code"), + "promo_invalid": MessageLookupByLibrary.simpleMessage( + "Promo code not found or expired", + ), "prostitution": MessageLookupByLibrary.simpleMessage("Проституция"), "proteins": MessageLookupByLibrary.simpleMessage("Белки"), "public_diet_state": MessageLookupByLibrary.simpleMessage("Публичная"), @@ -953,6 +967,7 @@ class MessageLookup extends MessageLookupByLibrary { "wrong_credentials": MessageLookupByLibrary.simpleMessage( "Неверные данные пользователя", ), + "yes": MessageLookupByLibrary.simpleMessage("Yes"), "youCanComplain": MessageLookupByLibrary.simpleMessage( "Если вас что-то не устроило, вы можете подать жалобу", ), diff --git a/lib/generated/intl/messages_ru.dart b/lib/generated/intl/messages_ru.dart index b9c4300d..884317a4 100644 --- a/lib/generated/intl/messages_ru.dart +++ b/lib/generated/intl/messages_ru.dart @@ -45,6 +45,7 @@ class MessageLookup extends MessageLookupByLibrary { ), "another_form_of_day": MessageLookupByLibrary.simpleMessage("дня"), "answer": MessageLookupByLibrary.simpleMessage("Ответить"), + "apply": MessageLookupByLibrary.simpleMessage("Применить"), "archived": MessageLookupByLibrary.simpleMessage("В архиве"), "authentificate": MessageLookupByLibrary.simpleMessage("Войти"), "authorization": MessageLookupByLibrary.simpleMessage("Авторизация"), @@ -154,6 +155,9 @@ class MessageLookup extends MessageLookupByLibrary { "enter_information": MessageLookupByLibrary.simpleMessage( "Введите информацию...", ), + "enter_promo_code": MessageLookupByLibrary.simpleMessage( + "Введите промокод", + ), "exit": MessageLookupByLibrary.simpleMessage("Выйти"), "expand": MessageLookupByLibrary.simpleMessage("Показать полностью"), "fats": MessageLookupByLibrary.simpleMessage("Жиры"), @@ -184,6 +188,12 @@ class MessageLookup extends MessageLookupByLibrary { "gallery": MessageLookupByLibrary.simpleMessage("Галерея"), "gr": MessageLookupByLibrary.simpleMessage("гр"), "grams": MessageLookupByLibrary.simpleMessage("гр"), + "have_promo_code": MessageLookupByLibrary.simpleMessage( + "У вас есть промокод?", + ), + "have_promo_code_form": MessageLookupByLibrary.simpleMessage( + "Есть промокод?", + ), "height_measurements": MessageLookupByLibrary.simpleMessage("Замер роста"), "hide": MessageLookupByLibrary.simpleMessage("Свернуть"), "hip_circumference": MessageLookupByLibrary.simpleMessage("Обхват бедер"), @@ -312,6 +322,10 @@ class MessageLookup extends MessageLookupByLibrary { "Разница в ценах, это комиссия Итвио", ), "profile": MessageLookupByLibrary.simpleMessage("Профиль"), + "promo_code": MessageLookupByLibrary.simpleMessage("Промокод"), + "promo_invalid": MessageLookupByLibrary.simpleMessage( + "Промокод не найден или истёк", + ), "proteins": MessageLookupByLibrary.simpleMessage("Белки"), "public_diet_state": MessageLookupByLibrary.simpleMessage("Публичная"), "publish": MessageLookupByLibrary.simpleMessage("Опубликовать"), @@ -413,6 +427,7 @@ class MessageLookup extends MessageLookupByLibrary { "wrong_credentials": MessageLookupByLibrary.simpleMessage( "Неверные данные пользователя", ), + "yes": MessageLookupByLibrary.simpleMessage("Да"), "you_will_get": MessageLookupByLibrary.simpleMessage("Вы получите"), "your_blood_pressure": MessageLookupByLibrary.simpleMessage( "Ваше артериальное давление?", diff --git a/lib/generated/l10n.dart b/lib/generated/l10n.dart index 41b13d8d..cf3507df 100644 --- a/lib/generated/l10n.dart +++ b/lib/generated/l10n.dart @@ -4178,6 +4178,61 @@ class S { args: [], ); } + + /// `Yes` + String get yes { + return Intl.message('Yes', name: 'yes', desc: '', args: []); + } + + /// `Apply` + String get apply { + return Intl.message('Apply', name: 'apply', desc: '', args: []); + } + + /// `Do you have a promo code?` + String get have_promo_code { + return Intl.message( + 'Do you have a promo code?', + name: 'have_promo_code', + desc: '', + args: [], + ); + } + + /// `Have a promo code?` + String get have_promo_code_form { + return Intl.message( + 'Have a promo code?', + name: 'have_promo_code_form', + desc: '', + args: [], + ); + } + + /// `Promo code` + String get promo_code { + return Intl.message('Promo code', name: 'promo_code', desc: '', args: []); + } + + /// `Enter promo code` + String get enter_promo_code { + return Intl.message( + 'Enter promo code', + name: 'enter_promo_code', + desc: '', + args: [], + ); + } + + /// `Promo code not found or expired` + String get promo_invalid { + return Intl.message( + 'Promo code not found or expired', + name: 'promo_invalid', + desc: '', + args: [], + ); + } } class AppLocalizationDelegate extends LocalizationsDelegate { diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index 855b25e8..9bb90bec 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -491,6 +491,13 @@ "video_call_permission_camera_title": "Camera access", "video_call_permission_camera_description": "Allow camera access so others can see you during the video call. If you declined, you can enable it in system settings.", "video_call_permission_microphone_title": "Microphone access", - "video_call_permission_microphone_description": "Allow microphone access so others can hear you during the video call. If you declined, you can enable it in system settings." + "video_call_permission_microphone_description": "Allow microphone access so others can hear you during the video call. If you declined, you can enable it in system settings.", + "yes": "Yes", + "apply": "Apply", + "have_promo_code": "Do you have a promo code?", + "have_promo_code_form": "Have a promo code?", + "promo_code": "Promo code", + "enter_promo_code": "Enter promo code", + "promo_invalid": "Promo code not found or expired" } diff --git a/lib/l10n/intl_ru.arb b/lib/l10n/intl_ru.arb index b160ec83..bae97144 100644 --- a/lib/l10n/intl_ru.arb +++ b/lib/l10n/intl_ru.arb @@ -236,5 +236,12 @@ "video_call_permission_microphone_description": "Чтобы вас слышали во время видеозвонка, разрешите доступ к микрофону. При отказе можно включить доступ в настройках устройства.", "choose_bank_for_payment": "Выберите банк", "no_installed_banks": "Нет установленных банков", - "sbp_payment_error": "Не удалось начать оплату. Попробуйте позже" + "sbp_payment_error": "Не удалось начать оплату. Попробуйте позже", + "yes": "Да", + "apply": "Применить", + "have_promo_code": "У вас есть промокод?", + "have_promo_code_form": "Есть промокод?", + "promo_code": "Промокод", + "enter_promo_code": "Введите промокод", + "promo_invalid": "Промокод не найден или истёк" } diff --git a/test/features/payment/consultation_payment_flow_test.dart b/test/features/payment/consultation_payment_flow_test.dart new file mode 100644 index 00000000..667c84ed --- /dev/null +++ b/test/features/payment/consultation_payment_flow_test.dart @@ -0,0 +1,131 @@ +import 'package:eatvio_app/generated/l10n.dart'; +import 'package:eatvio_app/features/payment/presentation/payment_flow.dart'; +import 'package:eatvio_app/features/payment/presentation/widgets/promo_code_modal.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + Widget createTestApp() { + return const MaterialApp( + localizationsDelegates: [ + S.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: [Locale('ru')], + home: Scaffold(body: SizedBox()), + ); + } + + group('showConsultationPaymentFlow', () { + late bool gateCalled; + late bool? gateResult; + late bool formCalled; + late PromoCodeResult? formResult; + late bool sbpCalled; + late int sbpPlanId; + + Future mockGate(BuildContext context) async { + gateCalled = true; + return gateResult; + } + + Future mockForm( + BuildContext context, { + Future Function(String)? onValidate, + }) async { + formCalled = true; + return formResult; + } + + Future mockSbp(BuildContext context, {required int planId}) async { + sbpCalled = true; + sbpPlanId = planId; + } + + setUp(() { + gateCalled = false; + gateResult = null; + formCalled = false; + formResult = null; + sbpCalled = false; + sbpPlanId = 0; + }); + + testWidgets('Нет → вызывает SbpBanks (не форму)', (tester) async { + gateResult = false; + await tester.pumpWidget(createTestApp()); + + await showConsultationPaymentFlow( + tester.element(find.byType(SizedBox)), + planId: 42, + showGate: mockGate, + showForm: mockForm, + showSbp: mockSbp, + ); + + expect(gateCalled, isTrue); + expect(formCalled, isFalse); + expect(sbpCalled, isTrue); + expect(sbpPlanId, 42); + }); + + testWidgets('Да → форма → Пропустить → вызывает SbpBanks', (tester) async { + gateResult = true; + formResult = PromoCodeResult(); + await tester.pumpWidget(createTestApp()); + + await showConsultationPaymentFlow( + tester.element(find.byType(SizedBox)), + planId: 42, + showGate: mockGate, + showForm: mockForm, + showSbp: mockSbp, + ); + + expect(gateCalled, isTrue); + expect(formCalled, isTrue); + expect(sbpCalled, isTrue); + expect(sbpPlanId, 42); + }); + + testWidgets('Да → форма отменена (null) → Sbp не вызывается', (tester) async { + gateResult = true; + formResult = null; + await tester.pumpWidget(createTestApp()); + + await showConsultationPaymentFlow( + tester.element(find.byType(SizedBox)), + planId: 42, + showGate: mockGate, + showForm: mockForm, + showSbp: mockSbp, + ); + + expect(gateCalled, isTrue); + expect(formCalled, isTrue); + expect(sbpCalled, isFalse); + }); + + testWidgets('гейт отменён (null) → ничего не вызывается', (tester) async { + gateResult = null; + await tester.pumpWidget(createTestApp()); + + await showConsultationPaymentFlow( + tester.element(find.byType(SizedBox)), + planId: 42, + showGate: mockGate, + showForm: mockForm, + showSbp: mockSbp, + ); + + expect(gateCalled, isTrue); + expect(formCalled, isFalse); + expect(sbpCalled, isFalse); + }); + }); +} diff --git a/test/features/payment/presentation/widgets/promo_code_gate_bottom_sheet_test.dart b/test/features/payment/presentation/widgets/promo_code_gate_bottom_sheet_test.dart new file mode 100644 index 00000000..48cf3859 --- /dev/null +++ b/test/features/payment/presentation/widgets/promo_code_gate_bottom_sheet_test.dart @@ -0,0 +1,65 @@ +import 'package:eatvio_app/core/theme/theme_helper.dart'; +import 'package:eatvio_app/core/widgets/bottom_sheet_handler_bar.dart'; +import 'package:eatvio_app/features/payment/presentation/widgets/promo_code_gate_bottom_sheet.dart'; +import 'package:eatvio_app/generated/l10n.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('PromoCodeGateBottomSheet', () { + Widget createTestWidget({required ValueChanged onResult}) { + return MaterialApp( + theme: ThemeHelper().themeData(), + localizationsDelegates: const [ + S.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: const [Locale('ru')], + home: Scaffold( + body: PromoCodeGateBottomSheet(onResult: onResult), + ), + ); + } + + testWidgets('показывает заголовок, BottomSheetHandlerBar и кнопки Да/Нет', (tester) async { + await tester.pumpWidget(createTestWidget(onResult: (_) {})); + await tester.pump(); + + expect(find.byType(BottomSheetHandlerBar), findsOneWidget); + expect(find.text('У вас есть промокод?'), findsOneWidget); + expect(find.text('Да'), findsOneWidget); + expect(find.text('Нет'), findsOneWidget); + }); + + testWidgets('tap Да вызывает onResult(true)', (tester) async { + bool? result; + await tester.pumpWidget( + createTestWidget(onResult: (v) => result = v), + ); + await tester.pump(); + + await tester.tap(find.text('Да')); + await tester.pump(); + + expect(result, isTrue); + }); + + testWidgets('tap Нет вызывает onResult(false)', (tester) async { + bool? result; + await tester.pumpWidget( + createTestWidget(onResult: (v) => result = v), + ); + await tester.pump(); + + await tester.tap(find.text('Нет')); + await tester.pump(); + + expect(result, isFalse); + }); + }); +} diff --git a/test/features/payment/presentation/widgets/promo_code_modal_test.dart b/test/features/payment/presentation/widgets/promo_code_modal_test.dart new file mode 100644 index 00000000..df2721e7 --- /dev/null +++ b/test/features/payment/presentation/widgets/promo_code_modal_test.dart @@ -0,0 +1,101 @@ +import 'package:eatvio_app/core/theme/theme_helper.dart'; +import 'package:eatvio_app/features/payment/presentation/widgets/promo_code_modal.dart'; +import 'package:eatvio_app/generated/l10n.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('ItvioPromoCodeModal', () { + Widget createTestWidget({ + Future Function(String)? onValidate, + }) { + return MaterialApp( + theme: ThemeHelper().themeData(), + localizationsDelegates: const [ + S.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: const [Locale('ru')], + home: Builder( + builder: (context) => ElevatedButton( + onPressed: () => ItvioPromoCodeModal.show( + context, + onValidate: onValidate, + ), + child: const Text('Open'), + ), + ), + ); + } + + testWidgets('показывает заголовок Есть промокод? и кнопки Применить/Пропустить', (tester) async { + await tester.pumpWidget(createTestWidget()); + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + expect(find.text('Есть промокод?'), findsOneWidget); + expect(find.text('Применить'), findsOneWidget); + expect(find.text('Пропустить'), findsOneWidget); + }); + + testWidgets('Применить с пустым полем показывает ошибку и не закрывает', (tester) async { + await tester.pumpWidget(createTestWidget()); + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Применить')); + await tester.pump(); + + expect(find.text('Введите промокод'), findsAtLeast(1)); + expect(find.text('Применить'), findsOneWidget); + }); + + testWidgets('Применить с вводом вызывает onValidate и показывает ошибку', (tester) async { + PromoValidation? captured; + await tester.pumpWidget( + createTestWidget( + onValidate: (code) async { + captured ??= PromoValidation.failure('Промокод не найден или истёк'); + return captured!; + }, + ), + ); + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + await tester.enterText(find.byType(TextField), 'TEST123'); + await tester.tap(find.text('Применить')); + await tester.pump(); + + expect(captured, isNotNull); + expect(find.text('Промокод не найден или истёк'), findsOneWidget); + expect(find.text('Применить'), findsOneWidget); + }); + + testWidgets('Пропустить закрывает шторку', (tester) async { + await tester.pumpWidget(createTestWidget()); + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Пропустить')); + await tester.pumpAndSettle(); + + expect(find.text('Есть промокод?'), findsNothing); + }); + + testWidgets('отсутствует UI applied-состояния (Промокод применён)', (tester) async { + await tester.pumpWidget(createTestWidget()); + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + expect(find.text('Промокод применён!'), findsNothing); + expect(find.text('Продолжить'), findsNothing); + expect(find.text('Убрать промокод'), findsNothing); + }); + }); +}