From 9e8b8b89fc38b876dc930de32721f6d997313127 Mon Sep 17 00:00:00 2001 From: misha Date: Mon, 3 Aug 2026 18:12:05 +0300 Subject: [PATCH] feat(promo_codes): added promo codes handling to app --- .gitignore | 3 +- lib/features/chat/services/chat_service.dart | 24 ++ .../presentation/widgets/plan_widget.dart | 4 +- .../models/promo_applied_list_response.dart | 15 ++ .../payment/models/promo_apply_response.dart | 21 ++ .../payment/models/promo_code_model.dart | 22 ++ .../payment/models/promo_remove_response.dart | 15 ++ .../models/promo_validate_response.dart | 20 ++ .../payment/presentation/payment_flow.dart | 160 +++++++++++- .../widgets/promo_code_modal.dart | 242 +++++++++++++++--- .../repositories/payment_repository.dart | 69 +++++ lib/generated/intl/messages_en.dart | 42 ++- lib/generated/intl/messages_ru.dart | 14 + lib/generated/l10n.dart | 50 ++++ lib/l10n/intl_en.arb | 11 + lib/l10n/intl_ru.arb | 11 + .../consultation_payment_flow_test.dart | 233 +++++++++++++++-- .../models/promo_response_models_test.dart | 108 ++++++++ .../widgets/promo_code_modal_test.dart | 83 +++++- 19 files changed, 1062 insertions(+), 85 deletions(-) create mode 100644 lib/features/payment/models/promo_applied_list_response.dart create mode 100644 lib/features/payment/models/promo_apply_response.dart create mode 100644 lib/features/payment/models/promo_code_model.dart create mode 100644 lib/features/payment/models/promo_remove_response.dart create mode 100644 lib/features/payment/models/promo_validate_response.dart create mode 100644 test/features/payment/models/promo_response_models_test.dart diff --git a/.gitignore b/.gitignore index 6b66540b..af0c7b6d 100644 --- a/.gitignore +++ b/.gitignore @@ -24,4 +24,5 @@ coverage/ backend/ .fvm/ .fvmrc -/eatvio \ No newline at end of file +/eatvio +*.code-workspace \ No newline at end of file diff --git a/lib/features/chat/services/chat_service.dart b/lib/features/chat/services/chat_service.dart index 2d5ceaac..3bd6a505 100644 --- a/lib/features/chat/services/chat_service.dart +++ b/lib/features/chat/services/chat_service.dart @@ -6,6 +6,10 @@ import 'package:eatvio_app/features/chat/models/chat_room_pagination_model.dart' import 'package:eatvio_app/features/chat/models/order_id_to_conversation_model.dart'; import 'package:eatvio_app/features/chat/models/payment_model.dart'; import 'package:eatvio_app/features/chat/models/plan_complain_model.dart'; +import 'package:eatvio_app/features/payment/models/promo_applied_list_response.dart'; +import 'package:eatvio_app/features/payment/models/promo_apply_response.dart'; +import 'package:eatvio_app/features/payment/models/promo_remove_response.dart'; +import 'package:eatvio_app/features/payment/models/promo_validate_response.dart'; import 'package:eatvio_app/features/plans/models/chosen_plan_model.dart'; import 'package:retrofit/retrofit.dart'; @@ -69,6 +73,26 @@ abstract class ChatService { @Body() required Map body, }); + @GET('/api/chosen_plans/applied_promos') + Future listAppliedPromoCodes(); + + @POST('/api/chosen_plans/{id}/validate_promo') + Future validatePromoCode({ + @Path() required int id, + @Body() required Map body, + }); + + @POST('/api/chosen_plans/{id}/apply_promo') + Future applyPromoCode({ + @Path() required int id, + @Body() required Map body, + }); + + @DELETE('/api/chosen_plans/{id}/remove_promo_code') + Future removePromoCode({ + @Path() required int id, + }); + @GET('/api/consultation/resolve/order_id_to_conversation/{orderId}') Future resolveOrderIdToConversation({ @Path() required String orderId, diff --git a/lib/features/feed/presentation/widgets/plan_widget.dart b/lib/features/feed/presentation/widgets/plan_widget.dart index 9b0a3f3d..96e2acff 100644 --- a/lib/features/feed/presentation/widgets/plan_widget.dart +++ b/lib/features/feed/presentation/widgets/plan_widget.dart @@ -116,9 +116,7 @@ class _PlanWidgetState extends State { Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: MarkdownHtmlWidget( - markdownText: widget.plan.details.length > 200 - ? '${widget.plan.details.substring(0, 200)}...' - : widget.plan.details, + markdownText: widget.plan.details.length > 200 ? '${widget.plan.details.substring(0, 200)}...' : widget.plan.details, textColor: appTheme.black, ), ), diff --git a/lib/features/payment/models/promo_applied_list_response.dart b/lib/features/payment/models/promo_applied_list_response.dart new file mode 100644 index 00000000..8d80c3e4 --- /dev/null +++ b/lib/features/payment/models/promo_applied_list_response.dart @@ -0,0 +1,15 @@ +import 'package:eatvio_app/features/payment/models/promo_code_model.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'promo_applied_list_response.freezed.dart'; +part 'promo_applied_list_response.g.dart'; + +@freezed +abstract class PromoAppliedListResponse with _$PromoAppliedListResponse { + const factory PromoAppliedListResponse({ + @JsonKey(name: 'promo_codes') required List promoCodes, + }) = _PromoAppliedListResponse; + + factory PromoAppliedListResponse.fromJson(Map json) => + _$PromoAppliedListResponseFromJson(json); +} diff --git a/lib/features/payment/models/promo_apply_response.dart b/lib/features/payment/models/promo_apply_response.dart new file mode 100644 index 00000000..0d3a5da5 --- /dev/null +++ b/lib/features/payment/models/promo_apply_response.dart @@ -0,0 +1,21 @@ +import 'package:eatvio_app/features/payment/models/promo_code_model.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'promo_apply_response.freezed.dart'; +part 'promo_apply_response.g.dart'; + +@freezed +abstract class PromoApplyResponse with _$PromoApplyResponse { + const factory PromoApplyResponse({ + required bool success, + String? error, + @JsonKey(name: 'promo_code') PromoCodeModel? promoCode, + @JsonKey(name: 'discount_percent') int? discountPercent, + @JsonKey(name: 'discount_amount_rub') double? discountAmountRub, + @JsonKey(name: 'original_amount_rub') double? originalAmountRub, + @JsonKey(name: 'final_amount_rub') double? finalAmountRub, + }) = _PromoApplyResponse; + + factory PromoApplyResponse.fromJson(Map json) => + _$PromoApplyResponseFromJson(json); +} diff --git a/lib/features/payment/models/promo_code_model.dart b/lib/features/payment/models/promo_code_model.dart new file mode 100644 index 00000000..4f3858ea --- /dev/null +++ b/lib/features/payment/models/promo_code_model.dart @@ -0,0 +1,22 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'promo_code_model.freezed.dart'; +part 'promo_code_model.g.dart'; + +@freezed +abstract class PromoCodeModel with _$PromoCodeModel { + const factory PromoCodeModel({ + required int id, + @JsonKey(name: 'chosen_plan_id') required int chosenPlanId, + @JsonKey(name: 'promo_code') required String promoCode, + @JsonKey(name: 'discount_percent') required int discountPercent, + @JsonKey(name: 'discount_amount_rub') required double discountAmountRub, + required String state, + @JsonKey(name: 'applied_at') String? appliedAt, + @JsonKey(name: 'confirmed_at') String? confirmedAt, + @JsonKey(name: 'cancelled_at') String? cancelledAt, + }) = _PromoCodeModel; + + factory PromoCodeModel.fromJson(Map json) => + _$PromoCodeModelFromJson(json); +} diff --git a/lib/features/payment/models/promo_remove_response.dart b/lib/features/payment/models/promo_remove_response.dart new file mode 100644 index 00000000..2d675e0c --- /dev/null +++ b/lib/features/payment/models/promo_remove_response.dart @@ -0,0 +1,15 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'promo_remove_response.freezed.dart'; +part 'promo_remove_response.g.dart'; + +@freezed +abstract class PromoRemoveResponse with _$PromoRemoveResponse { + const factory PromoRemoveResponse({ + required bool success, + String? error, + }) = _PromoRemoveResponse; + + factory PromoRemoveResponse.fromJson(Map json) => + _$PromoRemoveResponseFromJson(json); +} diff --git a/lib/features/payment/models/promo_validate_response.dart b/lib/features/payment/models/promo_validate_response.dart new file mode 100644 index 00000000..a07b3408 --- /dev/null +++ b/lib/features/payment/models/promo_validate_response.dart @@ -0,0 +1,20 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'promo_validate_response.freezed.dart'; +part 'promo_validate_response.g.dart'; + +@freezed +abstract class PromoValidateResponse with _$PromoValidateResponse { + const factory PromoValidateResponse({ + required bool valid, + String? error, + @JsonKey(name: 'promo_code') String? promoCode, + @JsonKey(name: 'discount_percent') int? discountPercent, + @JsonKey(name: 'discount_amount_rub') double? discountAmountRub, + @JsonKey(name: 'original_amount_rub') double? originalAmountRub, + @JsonKey(name: 'final_amount_rub') double? finalAmountRub, + }) = _PromoValidateResponse; + + factory PromoValidateResponse.fromJson(Map json) => + _$PromoValidateResponseFromJson(json); +} diff --git a/lib/features/payment/presentation/payment_flow.dart b/lib/features/payment/presentation/payment_flow.dart index 03cfe5b5..2764dc9e 100644 --- a/lib/features/payment/presentation/payment_flow.dart +++ b/lib/features/payment/presentation/payment_flow.dart @@ -1,22 +1,73 @@ +import 'package:eatvio_app/core/locator_service.dart'; +import 'package:eatvio_app/core/utils/toasts.dart'; +import 'package:eatvio_app/features/payment/models/promo_apply_response.dart'; +import 'package:eatvio_app/features/payment/models/promo_code_model.dart'; +import 'package:eatvio_app/features/payment/models/promo_validate_response.dart'; 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:eatvio_app/features/payment/repositories/payment_repository.dart'; import 'package:flutter/material.dart'; /// Оркестрирует флоу оплаты консультации: -/// гейт «У вас есть промокод?» → (Да → форма промокода) → SbpBanks. +/// applied rehydrate → (или гейт → форма) → apply при необходимости → SbpBanks. Future showConsultationPaymentFlow( BuildContext context, { required int planId, Future Function(BuildContext)? showGate, - Future Function(BuildContext, {Future Function(String)? onValidate})? showForm, + Future Function( + BuildContext, { + Future Function(String)? onValidate, + Future Function()? onRemove, + PromoCodeModel? initialPromo, + })? showForm, Future Function(BuildContext, {required int planId})? showSbp, + Future Function({ + required int planId, + required String promoCode, + })? validatePromo, + Future Function({ + required int planId, + required String promoCode, + })? applyPromo, + Future Function(int planId)? listApplied, + Future Function(int planId)? removePromo, }) async { final gate = showGate ?? _showGate; final form = showForm ?? _showForm; final sbp = showSbp ?? _showSbp; - final s = S.of(context); + final validate = validatePromo ?? _defaultValidatePromo; + final apply = applyPromo ?? _defaultApplyPromo; + final findApplied = listApplied ?? _defaultFindApplied; + final remove = removePromo ?? _defaultRemovePromo; + + final existing = await findApplied(planId); + if (!context.mounted) return; + + if (existing != null) { + final formResult = await form( + context, + initialPromo: existing, + onRemove: () => remove(planId), + onValidate: (code) => _mapValidateResponse( + validate(planId: planId, promoCode: code), + ), + ); + if (formResult == null || !context.mounted) return; + + if (formResult.hasPromoCode && !formResult.alreadyApplied) { + final applied = await _tryApplyPromo( + apply: apply, + planId: planId, + promoCode: formResult.code!, + ); + if (!applied || !context.mounted) return; + } + + if (!context.mounted) return; + await sbp(context, planId: planId); + return; + } final gateChoice = await gate(context); if (gateChoice == null || !context.mounted) return; @@ -24,9 +75,20 @@ Future showConsultationPaymentFlow( if (gateChoice) { final formResult = await form( context, - onValidate: (_) async => PromoValidation.failure(s.promo_invalid), + onValidate: (code) => _mapValidateResponse( + validate(planId: planId, promoCode: code), + ), ); if (formResult == null || !context.mounted) return; + + if (formResult.hasPromoCode && !formResult.alreadyApplied) { + final applied = await _tryApplyPromo( + apply: apply, + planId: planId, + promoCode: formResult.code!, + ); + if (!applied || !context.mounted) return; + } } if (!context.mounted) return; @@ -40,10 +102,96 @@ Future _showGate(BuildContext context) { Future _showForm( BuildContext context, { Future Function(String)? onValidate, + Future Function()? onRemove, + PromoCodeModel? initialPromo, }) { - return ItvioPromoCodeModal.show(context, onValidate: onValidate); + return ItvioPromoCodeModal.show( + context, + onValidate: onValidate, + onRemove: onRemove, + initialPromo: initialPromo, + ); } Future _showSbp(BuildContext context, {required int planId}) { return showSbpBanksBottomSheet(context, planId: planId); } + +Future _defaultValidatePromo({ + required int planId, + required String promoCode, +}) { + return sl().validatePromoCode( + planId: planId, + promoCode: promoCode, + ); +} + +Future _defaultApplyPromo({ + required int planId, + required String promoCode, +}) { + return sl().applyPromoCode( + planId: planId, + promoCode: promoCode, + ); +} + +Future _defaultFindApplied(int planId) { + return sl().findAppliedPromoForPlan(planId); +} + +Future _defaultRemovePromo(int planId) async { + try { + final response = await sl().removePromoCode(planId: planId); + if (response.success) return true; + showErrorToast(response.error ?? 'Не удалось убрать промокод.'); + return false; + } catch (_) { + showErrorToast('Не удалось убрать промокод. Попробуйте позже'); + return false; + } +} + +Future _mapValidateResponse( + Future request, +) async { + try { + final response = await request; + if (!response.valid) { + return PromoValidation.failure( + response.error ?? 'Промокод невалиден.', + ); + } + return PromoValidation.success( + discountPercent: response.discountPercent, + discountAmountRub: response.discountAmountRub, + originalAmountRub: response.originalAmountRub, + finalAmountRub: response.finalAmountRub, + ); + } catch (_) { + return const PromoValidation.failure( + 'Не удалось проверить промокод. Попробуйте позже', + ); + } +} + +Future _tryApplyPromo({ + required Future Function({ + required int planId, + required String promoCode, + }) apply, + required int planId, + required String promoCode, +}) async { + try { + final response = await apply(planId: planId, promoCode: promoCode); + if (response.success) return true; + + showErrorToast(response.error ?? 'Не удалось применить промокод.'); + return false; + } catch (_) { + showErrorToast('Не удалось применить промокод. Попробуйте позже'); + return false; + } +} diff --git a/lib/features/payment/presentation/widgets/promo_code_modal.dart b/lib/features/payment/presentation/widgets/promo_code_modal.dart index 0d4a9942..7de3eecf 100644 --- a/lib/features/payment/presentation/widgets/promo_code_modal.dart +++ b/lib/features/payment/presentation/widgets/promo_code_modal.dart @@ -1,45 +1,87 @@ 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/features/payment/models/promo_code_model.dart'; import 'package:eatvio_app/generated/l10n.dart'; import 'package:flutter/material.dart'; /// Результат работы модалки промокода. class PromoCodeResult { - const PromoCodeResult({this.code}); + const PromoCodeResult({ + this.code, + this.discountPercent, + this.discountAmountRub, + this.originalAmountRub, + this.finalAmountRub, + this.alreadyApplied = false, + }); /// Применённый промокод или null, если пользователь продолжил без него. final String? code; + final int? discountPercent; + final double? discountAmountRub; + final double? originalAmountRub; + final double? finalAmountRub; + + /// true — промокод уже на сервере (rehydrate Continue), apply не нужен. + final bool alreadyApplied; bool get hasPromoCode => code != null; } /// Ответ проверки промокода. class PromoValidation { - const PromoValidation.success() : error = null; - const PromoValidation.failure(this.error); + const PromoValidation.success({ + this.discountPercent, + this.discountAmountRub, + this.originalAmountRub, + this.finalAmountRub, + }) : error = null; + + const PromoValidation.failure(this.error) + : discountPercent = null, + discountAmountRub = null, + originalAmountRub = null, + finalAmountRub = null; final String? error; + final int? discountPercent; + final double? discountAmountRub; + final double? originalAmountRub; + final double? finalAmountRub; bool get isValid => error == null; } -/// Модальное окно ввода промокода. +/// Модальное окно ввода / просмотра применённого промокода. class ItvioPromoCodeModal extends StatefulWidget { - const ItvioPromoCodeModal({super.key, this.onValidate}); + const ItvioPromoCodeModal({ + super.key, + this.onValidate, + this.onRemove, + this.initialPromo, + }); final Future Function(String code)? onValidate; + final Future Function()? onRemove; + final PromoCodeModel? initialPromo; static Future show( BuildContext context, { Future Function(String code)? onValidate, + Future Function()? onRemove, + PromoCodeModel? initialPromo, }) { return showModalBottomSheet( context: context, isScrollControlled: true, useSafeArea: true, backgroundColor: Colors.transparent, - builder: (_) => ItvioPromoCodeModal(onValidate: onValidate), + builder: (_) => ItvioPromoCodeModal( + onValidate: onValidate, + onRemove: onRemove, + initialPromo: initialPromo, + ), ); } @@ -47,14 +89,32 @@ class ItvioPromoCodeModal extends StatefulWidget { State createState() => _ItvioPromoCodeModalState(); } -enum _ModalState { input, loading } +enum _ModalState { input, loading, applied } class _ItvioPromoCodeModalState extends State { final _controller = TextEditingController(); final _focusNode = FocusNode(); - _ModalState _state = _ModalState.input; + late _ModalState _state; String? _errorText; + int? _discountPercent; + double? _discountAmountRub; + String? _appliedCode; + + @override + void initState() { + super.initState(); + final initial = widget.initialPromo; + if (initial != null) { + _state = _ModalState.applied; + _appliedCode = initial.promoCode; + _discountPercent = initial.discountPercent; + _discountAmountRub = initial.discountAmountRub; + _controller.text = initial.promoCode; + } else { + _state = _ModalState.input; + } + } @override void dispose() { @@ -98,7 +158,62 @@ class _ItvioPromoCodeModalState extends State { return; } - Navigator.of(context).pop(PromoCodeResult(code: code)); + Navigator.of(context).pop( + PromoCodeResult( + code: code, + discountPercent: validation.discountPercent, + discountAmountRub: validation.discountAmountRub, + originalAmountRub: validation.originalAmountRub, + finalAmountRub: validation.finalAmountRub, + ), + ); + } + + void _onContinueApplied() { + Navigator.of(context).pop( + PromoCodeResult( + code: _appliedCode, + discountPercent: _discountPercent, + discountAmountRub: _discountAmountRub, + alreadyApplied: true, + ), + ); + } + + Future _onRemove() async { + setState(() { + _state = _ModalState.loading; + _errorText = null; + }); + + var success = true; + try { + if (widget.onRemove != null) { + success = await widget.onRemove!(); + } + } catch (_) { + success = false; + } + + if (!mounted) return; + + if (!success) { + setState(() { + _state = _ModalState.applied; + _errorText = S.of(context).promo_remove_failed; + }); + return; + } + + setState(() { + _state = _ModalState.input; + _appliedCode = null; + _discountPercent = null; + _discountAmountRub = null; + _errorText = null; + _controller.clear(); + }); + _focusNode.requestFocus(); } void _onSkip() { @@ -107,6 +222,10 @@ class _ItvioPromoCodeModalState extends State { bool get _isLoading => _state == _ModalState.loading; + /// Applied UI while loading remove (state=loading, but promo still shown). + bool get _isApplied => + _appliedCode != null && _state != _ModalState.input; + @override Widget build(BuildContext context) { final bottomInset = MediaQuery.of(context).viewInsets.bottom; @@ -127,40 +246,51 @@ class _ItvioPromoCodeModalState extends State { children: [ const Center(child: BottomSheetHandlerBar()), const SizedBox(height: 16), - _buildHeader(), + _buildHeader(s), + if (_isApplied) ...[ + const SizedBox(height: 6), + Text( + s.promo_applied_subtitle, + style: const TextStyle(fontSize: 14, color: Color(0xFF6B7684)), + ), + ], const SizedBox(height: 16), - const Text( - 'Промокод', - style: TextStyle( + Text( + s.promo_code, + style: const TextStyle( fontSize: 13, color: Color(0xFF6B7684), ), ), const SizedBox(height: 8), - _buildField(), + _buildField(s), const SizedBox(height: 8), - _buildHint(), + _buildHint(s), const SizedBox(height: 12), - _buildApplyButton(s), - const SizedBox(height: 6), - _buildSkipButton(s), + if (_isApplied) ...[ + _buildContinueButton(s), + const SizedBox(height: 6), + _buildRemoveButton(s), + ] else ...[ + _buildApplyButton(s), + const SizedBox(height: 6), + _buildSkipButton(s), + ], ], ), ), ); } - Widget _buildHeader() { - final s = S.of(context); + Widget _buildHeader(S s) { return Text( - s.have_promo_code_form, + _isApplied ? s.promo_applied_title : s.have_promo_code_form, style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black), ); } - Widget _buildField() { - final hasError = _errorText != null; - final s = S.of(context); + Widget _buildField(S s) { + final hasError = _errorText != null && !_isApplied; return AnimatedContainer( duration: const Duration(milliseconds: 150), @@ -171,7 +301,7 @@ class _ItvioPromoCodeModalState extends State { width: 2, color: hasError ? appTheme.red - : (_focusNode.hasFocus + : (_focusNode.hasFocus && !_isApplied ? appTheme.blueA400 : Colors.transparent), ), @@ -182,14 +312,16 @@ class _ItvioPromoCodeModalState extends State { child: TextField( controller: _controller, focusNode: _focusNode, - enabled: !_isLoading, + enabled: !_isLoading && !_isApplied, textCapitalization: TextCapitalization.characters, onChanged: (_) { if (_errorText != null) { setState(() => _errorText = null); } }, - onSubmitted: (_) => _apply(), + onSubmitted: (_) { + if (!_isApplied) _apply(); + }, decoration: InputDecoration( hintText: s.enter_promo_code, hintStyle: const TextStyle( @@ -202,9 +334,11 @@ class _ItvioPromoCodeModalState extends State { vertical: 14, ), ), - style: const TextStyle( + style: TextStyle( fontSize: 15, - color: Color(0xFF24303F), + color: _isApplied + ? const Color(0xFF6B7684) + : const Color(0xFF24303F), ), ), ), @@ -213,13 +347,17 @@ class _ItvioPromoCodeModalState extends State { ); } - Widget _buildHint() { + Widget _buildHint(S s) { final String text; final Color color; if (_errorText != null) { text = _errorText!; color = appTheme.red; + } else if (_isApplied && _discountPercent != null) { + final amount = (_discountAmountRub ?? 0).toStringAsFixed(0); + text = s.promo_discount_hint(_discountPercent!, amount); + color = const Color(0xFF27A06B); } else { text = ''; color = Colors.transparent; @@ -264,6 +402,50 @@ class _ItvioPromoCodeModalState extends State { ); } + Widget _buildContinueButton(S s) { + return SizedBox( + height: 50, + child: FilledButton( + onPressed: _isLoading ? null : _onContinueApplied, + 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: Text(s.continue_), + ), + ); + } + + Widget _buildRemoveButton(S s) { + return SizedBox( + height: 46, + child: TextButton( + onPressed: _isLoading ? null : _onRemove, + style: TextButton.styleFrom( + foregroundColor: appTheme.blueA400, + shape: const StadiumBorder(), + textStyle: const TextStyle(fontSize: 14), + ), + child: _isLoading + ? SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2.5, + color: appTheme.blueA400, + ), + ) + : Text(s.promo_remove), + ), + ); + } + Widget _buildSkipButton(S s) { return SizedBox( height: 46, diff --git a/lib/features/payment/repositories/payment_repository.dart b/lib/features/payment/repositories/payment_repository.dart index 1d590e48..00d2ca75 100644 --- a/lib/features/payment/repositories/payment_repository.dart +++ b/lib/features/payment/repositories/payment_repository.dart @@ -1,5 +1,11 @@ +import 'package:dio/dio.dart'; import 'package:eatvio_app/features/chat/models/payment_model.dart'; import 'package:eatvio_app/features/chat/services/chat_service.dart'; +import 'package:eatvio_app/features/payment/models/promo_applied_list_response.dart'; +import 'package:eatvio_app/features/payment/models/promo_apply_response.dart'; +import 'package:eatvio_app/features/payment/models/promo_code_model.dart'; +import 'package:eatvio_app/features/payment/models/promo_remove_response.dart'; +import 'package:eatvio_app/features/payment/models/promo_validate_response.dart'; /// Репозиторий оплаты консультаций (chosen_plan) через Alfa Bank / СБП. class PaymentRepository { @@ -15,4 +21,67 @@ class PaymentRepository { body: const {'payment_provider': 'alfa-bank'}, ); } + + /// Список applied-промокодов текущего пользователя. + Future listAppliedPromoCodes() { + return chatService.listAppliedPromoCodes(); + } + + /// Applied-промокод для конкретного chosen_plan, либо null (нет / ошибка сети). + Future findAppliedPromoForPlan(int planId) async { + try { + final response = await listAppliedPromoCodes(); + for (final promo in response.promoCodes) { + if (promo.chosenPlanId == planId && promo.state == 'applied') { + return promo; + } + } + return null; + } catch (_) { + return null; + } + } + + /// Preview-валидация промокода без применения. + Future validatePromoCode({ + required int planId, + required String promoCode, + }) { + return chatService.validatePromoCode( + id: planId, + body: {'promo_code': promoCode}, + ); + } + + /// Применяет промокод к chosen_plan. При HTTP 422 возвращает тело ответа. + Future applyPromoCode({ + required int planId, + required String promoCode, + }) async { + try { + return await chatService.applyPromoCode( + id: planId, + body: {'promo_code': promoCode}, + ); + } on DioException catch (e) { + final data = e.response?.data; + if (data is Map) { + return PromoApplyResponse.fromJson(data); + } + rethrow; + } + } + + /// Удаляет применённый промокод (idempotent). + Future removePromoCode({required int planId}) async { + try { + return await chatService.removePromoCode(id: planId); + } on DioException catch (e) { + final data = e.response?.data; + if (data is Map) { + return PromoRemoveResponse.fromJson(data); + } + rethrow; + } + } } diff --git a/lib/generated/intl/messages_en.dart b/lib/generated/intl/messages_en.dart index e563a193..81d5acf0 100644 --- a/lib/generated/intl/messages_en.dart +++ b/lib/generated/intl/messages_en.dart @@ -27,24 +27,27 @@ class MessageLookup extends MessageLookupByLibrary { static String m2(planName, name) => "План «${planName}» с пользователем ${name} начался"; - static String m3(step) => "шаг ${step} из 2"; + static String m3(percent, amount) => + "Discount ${percent}% — you save ${amount} ₽"; - static String m4(planName, name) => + static String m4(step) => "шаг ${step} из 2"; + + static String m5(planName, name) => "Завтра начинается план «${planName}» с пользователем ${name}"; - static String m5(planName) => + static String m6(planName) => "Завтра начнется прохождение плана «${planName}»"; - static String m6(name, planName) => + static String m7(name, planName) => "Пользователь ${name} отменил подписку на план «${planName}»"; - static String m7(name, planName) => + static String m8(name, planName) => "Пользователь ${name} оплатил план «${planName}»"; - static String m8(name, planName) => + static String m9(name, planName) => "Пользователь ${name} подписался на план «${planName}»"; - static String m9(planName) => "Вы отменили подписку на план «${planName}»"; + static String m10(planName) => "Вы отменили подписку на план «${planName}»"; final messages = _notInlinedMessages(_notInlinedMessages); static Map _notInlinedMessages(_) => { @@ -715,10 +718,21 @@ class MessageLookup extends MessageLookupByLibrary { "Разница в ценах, это комиссия Итвио", ), "profile": MessageLookupByLibrary.simpleMessage("Профиль"), + "promo_applied_subtitle": MessageLookupByLibrary.simpleMessage( + "The discount will be applied at checkout", + ), + "promo_applied_title": MessageLookupByLibrary.simpleMessage( + "Promo code applied!", + ), "promo_code": MessageLookupByLibrary.simpleMessage("Promo code"), + "promo_discount_hint": m3, "promo_invalid": MessageLookupByLibrary.simpleMessage( "Promo code not found or expired", ), + "promo_remove": MessageLookupByLibrary.simpleMessage("Remove promo code"), + "promo_remove_failed": MessageLookupByLibrary.simpleMessage( + "Could not remove the promo code. Try again later", + ), "prostitution": MessageLookupByLibrary.simpleMessage("Проституция"), "proteins": MessageLookupByLibrary.simpleMessage("Белки"), "public_diet_state": MessageLookupByLibrary.simpleMessage("Публичная"), @@ -809,7 +823,7 @@ class MessageLookup extends MessageLookupByLibrary { "step_by_step_photo_recipe": MessageLookupByLibrary.simpleMessage( "Пошаговый фоторецепт", ), - "step_widget": m3, + "step_widget": m4, "successfully_published": MessageLookupByLibrary.simpleMessage( "успешно опубликован", ), @@ -834,8 +848,8 @@ class MessageLookup extends MessageLookupByLibrary { "toOffer": MessageLookupByLibrary.simpleMessage("Предложить"), "to_draft": MessageLookupByLibrary.simpleMessage("В черновик"), "to_order": MessageLookupByLibrary.simpleMessage("Заказать"), - "tommorowPlanWithUserWillStart": m4, - "tomorrowYourPlanWillStart": m5, + "tommorowPlanWithUserWillStart": m5, + "tomorrowYourPlanWillStart": m6, "try_again": MessageLookupByLibrary.simpleMessage("Попробовать снова"), "try_again_2": MessageLookupByLibrary.simpleMessage("Попробуйте ещё раз"), "typeAmoutOfReturn": MessageLookupByLibrary.simpleMessage( @@ -869,7 +883,7 @@ class MessageLookup extends MessageLookupByLibrary { "Верхние показатели давления:", ), "upper": MessageLookupByLibrary.simpleMessage("Верхнее"), - "userCanceldThePlanSubscription": m6, + "userCanceldThePlanSubscription": m7, "userConfirmedPlanCompletion": MessageLookupByLibrary.simpleMessage( "Пользователь подтвердил прохождения плана. В ближайшее время Вы получите", ), @@ -877,8 +891,8 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage( "Пользователь подтвердил прохождения плана.", ), - "userPaidThePlan": m7, - "userSubscribedToPlan": m8, + "userPaidThePlan": m8, + "userSubscribedToPlan": m9, "user_authorization": MessageLookupByLibrary.simpleMessage( "Аутентификация", ), @@ -1008,7 +1022,7 @@ class MessageLookup extends MessageLookupByLibrary { "youCanComplain": MessageLookupByLibrary.simpleMessage( "Если вас что-то не устроило, вы можете подать жалобу", ), - "youCanceledSubscriptionToPlan": m9, + "youCanceledSubscriptionToPlan": m10, "youShureYouWantToCancelOrder": MessageLookupByLibrary.simpleMessage( "Вы уверены, что хотите отменить заказ?", ), diff --git a/lib/generated/intl/messages_ru.dart b/lib/generated/intl/messages_ru.dart index 5783d1f0..a1c9f2c3 100644 --- a/lib/generated/intl/messages_ru.dart +++ b/lib/generated/intl/messages_ru.dart @@ -20,6 +20,9 @@ typedef String MessageIfAbsent(String messageStr, List args); class MessageLookup extends MessageLookupByLibrary { String get localeName => 'ru'; + static String m3(percent, amount) => + "Скидка ${percent}% — вы экономите ${amount} ₽"; + final messages = _notInlinedMessages(_notInlinedMessages); static Map _notInlinedMessages(_) => { "Moscow": MessageLookupByLibrary.simpleMessage("Москва"), @@ -322,10 +325,21 @@ class MessageLookup extends MessageLookupByLibrary { "Разница в ценах, это комиссия Итвио", ), "profile": MessageLookupByLibrary.simpleMessage("Профиль"), + "promo_applied_subtitle": MessageLookupByLibrary.simpleMessage( + "Скидка учтётся при оплате", + ), + "promo_applied_title": MessageLookupByLibrary.simpleMessage( + "Промокод применён!", + ), "promo_code": MessageLookupByLibrary.simpleMessage("Промокод"), + "promo_discount_hint": m3, "promo_invalid": MessageLookupByLibrary.simpleMessage( "Промокод не найден или истёк", ), + "promo_remove": MessageLookupByLibrary.simpleMessage("Убрать промокод"), + "promo_remove_failed": MessageLookupByLibrary.simpleMessage( + "Не удалось убрать промокод. Попробуйте позже", + ), "proteins": MessageLookupByLibrary.simpleMessage("Белки"), "public_diet_state": MessageLookupByLibrary.simpleMessage("Публичная"), "publish": MessageLookupByLibrary.simpleMessage("Опубликовать"), diff --git a/lib/generated/l10n.dart b/lib/generated/l10n.dart index 6076d4b0..d5c739d2 100644 --- a/lib/generated/l10n.dart +++ b/lib/generated/l10n.dart @@ -4264,6 +4264,56 @@ class S { ); } + /// `Promo code applied!` + String get promo_applied_title { + return Intl.message( + 'Promo code applied!', + name: 'promo_applied_title', + desc: '', + args: [], + ); + } + + /// `The discount will be applied at checkout` + String get promo_applied_subtitle { + return Intl.message( + 'The discount will be applied at checkout', + name: 'promo_applied_subtitle', + desc: '', + args: [], + ); + } + + /// `Remove promo code` + String get promo_remove { + return Intl.message( + 'Remove promo code', + name: 'promo_remove', + desc: '', + args: [], + ); + } + + /// `Discount {percent}% — you save {amount} ₽` + String promo_discount_hint(int percent, String amount) { + return Intl.message( + 'Discount $percent% — you save $amount ₽', + name: 'promo_discount_hint', + desc: '', + args: [percent, amount], + ); + } + + /// `Could not remove the promo code. Try again later` + String get promo_remove_failed { + return Intl.message( + 'Could not remove the promo code. Try again later', + name: 'promo_remove_failed', + desc: '', + args: [], + ); + } + /// `Loading…` String get video_call_screen_loading_title { return Intl.message( diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index d884d273..2c971d99 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -502,6 +502,17 @@ "promo_code": "Promo code", "enter_promo_code": "Enter promo code", "promo_invalid": "Promo code not found or expired", + "promo_applied_title": "Promo code applied!", + "promo_applied_subtitle": "The discount will be applied at checkout", + "promo_remove": "Remove promo code", + "promo_discount_hint": "Discount {percent}% — you save {amount} ₽", + "@promo_discount_hint": { + "placeholders": { + "percent": {"type": "int"}, + "amount": {"type": "String"} + } + }, + "promo_remove_failed": "Could not remove the promo code. Try again later", "video_call_screen_loading_title": "Loading…", "video_call_screen_connecting_title": "Connecting to\nthe consultation", "video_call_screen_connecting_subtitle": "This may take a few seconds", diff --git a/lib/l10n/intl_ru.arb b/lib/l10n/intl_ru.arb index 1a6bf995..738bac08 100644 --- a/lib/l10n/intl_ru.arb +++ b/lib/l10n/intl_ru.arb @@ -246,6 +246,17 @@ "promo_code": "Промокод", "enter_promo_code": "Введите промокод", "promo_invalid": "Промокод не найден или истёк", + "promo_applied_title": "Промокод применён!", + "promo_applied_subtitle": "Скидка учтётся при оплате", + "promo_remove": "Убрать промокод", + "promo_discount_hint": "Скидка {percent}% — вы экономите {amount} ₽", + "@promo_discount_hint": { + "placeholders": { + "percent": {"type": "int"}, + "amount": {"type": "String"} + } + }, + "promo_remove_failed": "Не удалось убрать промокод. Попробуйте позже", "video_call_permission_microphone_description": "Чтобы вас слышали во время видеозвонка, разрешите доступ к микрофону. При отказе можно включить доступ в настройках устройства.", "video_call_screen_loading_title": "Загрузка…", "video_call_screen_connecting_title": "Подключение\nк консультации", diff --git a/test/features/payment/consultation_payment_flow_test.dart b/test/features/payment/consultation_payment_flow_test.dart index f0544e6d..8b532853 100644 --- a/test/features/payment/consultation_payment_flow_test.dart +++ b/test/features/payment/consultation_payment_flow_test.dart @@ -1,6 +1,9 @@ -import 'package:eatvio_app/generated/l10n.dart'; +import 'package:eatvio_app/features/payment/models/promo_apply_response.dart'; +import 'package:eatvio_app/features/payment/models/promo_code_model.dart'; +import 'package:eatvio_app/features/payment/models/promo_validate_response.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:eatvio_app/generated/l10n.dart'; import 'package:flutter/material.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -26,8 +29,17 @@ void main() { late bool? gateResult; late bool formCalled; late PromoCodeResult? formResult; + late PromoCodeModel? formInitialPromo; late bool sbpCalled; late int sbpPlanId; + late int validateCalls; + late int applyCalls; + late String? lastValidatedCode; + late String? lastAppliedCode; + late PromoValidateResponse validateResponse; + late PromoApplyResponse applyResponse; + late bool applyThrows; + late PromoCodeModel? listAppliedResult; Future mockGate(BuildContext context) async { gateCalled = true; @@ -37,8 +49,27 @@ void main() { Future mockForm( BuildContext context, { Future Function(String)? onValidate, + Future Function()? onRemove, + PromoCodeModel? initialPromo, }) async { formCalled = true; + formInitialPromo = initialPromo; + + if (formResult?.hasPromoCode == true && + !formResult!.alreadyApplied && + onValidate != null) { + final validation = await onValidate(formResult!.code!); + if (!validation.isValid) { + return null; + } + return PromoCodeResult( + code: formResult!.code, + discountPercent: validation.discountPercent, + discountAmountRub: validation.discountAmountRub, + originalAmountRub: validation.originalAmountRub, + finalAmountRub: validation.finalAmountRub, + ); + } return formResult; } @@ -47,31 +78,86 @@ void main() { sbpPlanId = planId; } + Future mockValidate({ + required int planId, + required String promoCode, + }) async { + validateCalls++; + lastValidatedCode = promoCode; + return validateResponse; + } + + Future mockApply({ + required int planId, + required String promoCode, + }) async { + applyCalls++; + lastAppliedCode = promoCode; + if (applyThrows) { + throw Exception('network'); + } + return applyResponse; + } + + Future mockListApplied(int planId) async { + return listAppliedResult; + } + + Future mockRemove(int planId) async { + return true; + } + setUp(() { gateCalled = false; gateResult = null; formCalled = false; formResult = null; + formInitialPromo = null; sbpCalled = false; sbpPlanId = 0; + validateCalls = 0; + applyCalls = 0; + lastValidatedCode = null; + lastAppliedCode = null; + listAppliedResult = null; + validateResponse = const PromoValidateResponse( + valid: true, + promoCode: 'TEST123', + discountPercent: 5, + discountAmountRub: 60, + originalAmountRub: 1200, + finalAmountRub: 1140, + ); + applyResponse = const PromoApplyResponse(success: true); + applyThrows = false; }); - testWidgets('Нет → вызывает SbpBanks (не форму)', (tester) async { - gateResult = false; - await tester.pumpWidget(createTestApp()); - - await showConsultationPaymentFlow( + Future runFlow(WidgetTester tester) { + return showConsultationPaymentFlow( tester.element(find.byType(SizedBox)), planId: 42, showGate: mockGate, showForm: mockForm, showSbp: mockSbp, + validatePromo: mockValidate, + applyPromo: mockApply, + listApplied: mockListApplied, + removePromo: mockRemove, ); + } + + testWidgets('Нет → вызывает SbpBanks (не форму)', (tester) async { + gateResult = false; + await tester.pumpWidget(createTestApp()); + + await runFlow(tester); expect(gateCalled, isTrue); expect(formCalled, isFalse); expect(sbpCalled, isTrue); expect(sbpPlanId, 42); + expect(validateCalls, 0); + expect(applyCalls, 0); }); testWidgets('Да → форма → Пропустить → вызывает SbpBanks', (tester) async { @@ -79,18 +165,13 @@ void main() { formResult = const PromoCodeResult(); await tester.pumpWidget(createTestApp()); - await showConsultationPaymentFlow( - tester.element(find.byType(SizedBox)), - planId: 42, - showGate: mockGate, - showForm: mockForm, - showSbp: mockSbp, - ); + await runFlow(tester); expect(gateCalled, isTrue); expect(formCalled, isTrue); expect(sbpCalled, isTrue); expect(sbpPlanId, 42); + expect(applyCalls, 0); }); testWidgets('Да → форма отменена (null) → Sbp не вызывается', (tester) async { @@ -98,13 +179,7 @@ void main() { formResult = null; await tester.pumpWidget(createTestApp()); - await showConsultationPaymentFlow( - tester.element(find.byType(SizedBox)), - planId: 42, - showGate: mockGate, - showForm: mockForm, - showSbp: mockSbp, - ); + await runFlow(tester); expect(gateCalled, isTrue); expect(formCalled, isTrue); @@ -115,17 +190,121 @@ void main() { gateResult = null; await tester.pumpWidget(createTestApp()); - await showConsultationPaymentFlow( - tester.element(find.byType(SizedBox)), - planId: 42, - showGate: mockGate, - showForm: mockForm, - showSbp: mockSbp, - ); + await runFlow(tester); expect(gateCalled, isTrue); expect(formCalled, isFalse); expect(sbpCalled, isFalse); }); + + testWidgets('Да → код → validate + apply → SbpBanks', (tester) async { + gateResult = true; + formResult = const PromoCodeResult(code: 'TEST123'); + await tester.pumpWidget(createTestApp()); + + await runFlow(tester); + + expect(validateCalls, 1); + expect(lastValidatedCode, 'TEST123'); + expect(applyCalls, 1); + expect(lastAppliedCode, 'TEST123'); + expect(sbpCalled, isTrue); + expect(sbpPlanId, 42); + }); + + testWidgets('Да → код → apply fail → Sbp не вызывается', (tester) async { + gateResult = true; + formResult = const PromoCodeResult(code: 'BAD'); + applyResponse = const PromoApplyResponse( + success: false, + error: 'Промокод уже использован', + ); + await tester.pumpWidget(createTestApp()); + + await runFlow(tester); + + expect(applyCalls, 1); + expect(sbpCalled, isFalse); + }); + + testWidgets('applied rehydrate → Continue → SBP без apply и без гейта', (tester) async { + listAppliedResult = const PromoCodeModel( + id: 1, + chosenPlanId: 42, + promoCode: 'SAVE5', + discountPercent: 5, + discountAmountRub: 60, + state: 'applied', + ); + formResult = const PromoCodeResult( + code: 'SAVE5', + discountPercent: 5, + discountAmountRub: 60, + alreadyApplied: true, + ); + await tester.pumpWidget(createTestApp()); + + await runFlow(tester); + + expect(gateCalled, isFalse); + expect(formCalled, isTrue); + expect(formInitialPromo?.promoCode, 'SAVE5'); + expect(applyCalls, 0); + expect(sbpCalled, isTrue); + }); + + testWidgets('list пустой → идёт через гейт', (tester) async { + listAppliedResult = null; + gateResult = false; + await tester.pumpWidget(createTestApp()); + + await runFlow(tester); + + expect(gateCalled, isTrue); + expect(formCalled, isFalse); + expect(sbpCalled, isTrue); + }); + + testWidgets('rehydrate → Remove → новый код → apply → SBP', (tester) async { + listAppliedResult = const PromoCodeModel( + id: 1, + chosenPlanId: 42, + promoCode: 'OLD', + discountPercent: 5, + discountAmountRub: 60, + state: 'applied', + ); + formResult = const PromoCodeResult(code: 'NEW123'); + await tester.pumpWidget(createTestApp()); + + await runFlow(tester); + + expect(gateCalled, isFalse); + expect(formCalled, isTrue); + expect(validateCalls, 1); + expect(applyCalls, 1); + expect(lastAppliedCode, 'NEW123'); + expect(sbpCalled, isTrue); + }); + + testWidgets('rehydrate → Remove → Skip → SBP без apply', (tester) async { + listAppliedResult = const PromoCodeModel( + id: 1, + chosenPlanId: 42, + promoCode: 'OLD', + discountPercent: 5, + discountAmountRub: 60, + state: 'applied', + ); + formResult = const PromoCodeResult(); + await tester.pumpWidget(createTestApp()); + + await runFlow(tester); + + expect(gateCalled, isFalse); + expect(formCalled, isTrue); + expect(applyCalls, 0); + expect(sbpCalled, isTrue); + }); }); } diff --git a/test/features/payment/models/promo_response_models_test.dart b/test/features/payment/models/promo_response_models_test.dart new file mode 100644 index 00000000..d9c65468 --- /dev/null +++ b/test/features/payment/models/promo_response_models_test.dart @@ -0,0 +1,108 @@ +import 'package:eatvio_app/features/payment/models/promo_applied_list_response.dart'; +import 'package:eatvio_app/features/payment/models/promo_apply_response.dart'; +import 'package:eatvio_app/features/payment/models/promo_code_model.dart'; +import 'package:eatvio_app/features/payment/models/promo_validate_response.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('PromoValidateResponse', () { + test('parses valid preview', () { + final response = PromoValidateResponse.fromJson({ + 'valid': true, + 'promo_code': 'PROMO_100117_A', + 'discount_percent': 5, + 'discount_amount_rub': 60.0, + 'original_amount_rub': 1200.0, + 'final_amount_rub': 1140.0, + }); + + expect(response.valid, isTrue); + expect(response.promoCode, 'PROMO_100117_A'); + expect(response.discountPercent, 5); + expect(response.finalAmountRub, 1140.0); + expect(response.error, isNull); + }); + + test('parses invalid preview with HTTP 200 body', () { + final response = PromoValidateResponse.fromJson({ + 'valid': false, + 'error': 'Промокод «UNKNOWN» не найден в системе.', + }); + + expect(response.valid, isFalse); + expect(response.error, contains('не найден')); + }); + }); + + group('PromoApplyResponse', () { + test('parses success with nested promo_code', () { + final response = PromoApplyResponse.fromJson({ + 'success': true, + 'promo_code': { + 'id': 1, + 'chosen_plan_id': 42, + 'promo_code': 'PROMO_100117_A', + 'discount_percent': 5, + 'discount_amount_rub': 60.0, + 'state': 'applied', + 'applied_at': '2024-01-15T10:30:00+00:00', + 'confirmed_at': null, + 'cancelled_at': null, + }, + 'discount_percent': 5, + 'discount_amount_rub': 60.0, + 'original_amount_rub': 1200.0, + 'final_amount_rub': 1140.0, + }); + + expect(response.success, isTrue); + expect(response.promoCode, isA()); + expect(response.promoCode!.promoCode, 'PROMO_100117_A'); + expect(response.promoCode!.state, 'applied'); + expect(response.finalAmountRub, 1140.0); + }); + + test('parses failure body', () { + final response = PromoApplyResponse.fromJson({ + 'success': false, + 'error': 'Промокод «X» уже был использован вами ранее.', + }); + + expect(response.success, isFalse); + expect(response.error, contains('уже был использован')); + expect(response.promoCode, isNull); + }); + }); + + group('PromoAppliedListResponse', () { + test('parses promo_codes list', () { + final response = PromoAppliedListResponse.fromJson({ + 'promo_codes': [ + { + 'id': 2, + 'chosen_plan_id': 42, + 'promo_code': 'PROMO_LIST_B', + 'discount_percent': 3, + 'discount_amount_rub': 36.0, + 'state': 'applied', + 'applied_at': '2024-01-15T10:30:00+00:00', + 'confirmed_at': null, + 'cancelled_at': null, + }, + ], + }); + + expect(response.promoCodes, hasLength(1)); + expect(response.promoCodes.first.promoCode, 'PROMO_LIST_B'); + expect(response.promoCodes.first.chosenPlanId, 42); + }); + + test('parses empty list', () { + final response = PromoAppliedListResponse.fromJson({ + 'promo_codes': [], + }); + + expect(response.promoCodes, isEmpty); + }); + }); +} diff --git a/test/features/payment/presentation/widgets/promo_code_modal_test.dart b/test/features/payment/presentation/widgets/promo_code_modal_test.dart index e1b2e923..56d10633 100644 --- a/test/features/payment/presentation/widgets/promo_code_modal_test.dart +++ b/test/features/payment/presentation/widgets/promo_code_modal_test.dart @@ -1,4 +1,5 @@ import 'package:eatvio_app/core/theme/theme_helper.dart'; +import 'package:eatvio_app/features/payment/models/promo_code_model.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'; @@ -11,6 +12,8 @@ void main() { group('ItvioPromoCodeModal', () { Widget createTestWidget({ Future Function(String)? onValidate, + Future Function()? onRemove, + PromoCodeModel? initialPromo, }) { return MaterialApp( theme: ThemeHelper().themeData(), @@ -26,6 +29,8 @@ void main() { onPressed: () => ItvioPromoCodeModal.show( context, onValidate: onValidate, + onRemove: onRemove, + initialPromo: initialPromo, ), child: const Text('Open'), ), @@ -88,14 +93,84 @@ void main() { expect(find.text('Есть промокод?'), findsNothing); }); - testWidgets('отсутствует UI applied-состояния (Промокод применён)', (tester) async { - await tester.pumpWidget(createTestWidget()); + testWidgets('initialPromo показывает applied UI', (tester) async { + await tester.pumpWidget( + createTestWidget( + initialPromo: const PromoCodeModel( + id: 1, + chosenPlanId: 42, + promoCode: 'SAVE5', + discountPercent: 5, + discountAmountRub: 60, + state: 'applied', + ), + onRemove: () async => true, + ), + ); await tester.tap(find.text('Open')); await tester.pumpAndSettle(); + expect(find.text('Промокод применён!'), findsOneWidget); + expect(find.text('Продолжить'), findsOneWidget); + expect(find.text('Убрать промокод'), findsOneWidget); + expect(find.text('Применить'), findsNothing); + }); + + testWidgets('Убрать промокод переводит в input', (tester) async { + var removeCalled = false; + await tester.pumpWidget( + createTestWidget( + initialPromo: const PromoCodeModel( + id: 1, + chosenPlanId: 42, + promoCode: 'SAVE5', + discountPercent: 5, + discountAmountRub: 60, + state: 'applied', + ), + onRemove: () async { + removeCalled = true; + return true; + }, + ), + ); + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Убрать промокод')); + await tester.pumpAndSettle(); + + expect(removeCalled, isTrue); + expect(find.text('Есть промокод?'), findsOneWidget); + expect(find.text('Применить'), findsOneWidget); + expect(find.text('Пропустить'), findsOneWidget); expect(find.text('Промокод применён!'), findsNothing); - expect(find.text('Продолжить'), findsNothing); - expect(find.text('Убрать промокод'), findsNothing); + }); + + testWidgets('Применить с успешным onValidate закрывает шторку', (tester) async { + String? validatedCode; + await tester.pumpWidget( + createTestWidget( + onValidate: (code) async { + validatedCode = code; + return const PromoValidation.success( + discountPercent: 5, + discountAmountRub: 60, + originalAmountRub: 1200, + finalAmountRub: 1140, + ); + }, + ), + ); + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + await tester.enterText(find.byType(TextField), 'save5'); + await tester.tap(find.text('Применить')); + await tester.pumpAndSettle(); + + expect(validatedCode, 'SAVE5'); + expect(find.text('Есть промокод?'), findsNothing); }); }); }