Merge pull request 'promo_codes' (#1017) from promo_codes into main

Reviewed-on: eatvio/eatvio_app#1017
This commit is contained in:
2026-08-04 11:14:47 +00:00
19 changed files with 1062 additions and 85 deletions

3
.gitignore vendored
View File

@@ -24,4 +24,5 @@ coverage/
backend/
.fvm/
.fvmrc
/eatvio
/eatvio
*.code-workspace

View File

@@ -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<String, String> body,
});
@GET('/api/chosen_plans/applied_promos')
Future<PromoAppliedListResponse> listAppliedPromoCodes();
@POST('/api/chosen_plans/{id}/validate_promo')
Future<PromoValidateResponse> validatePromoCode({
@Path() required int id,
@Body() required Map<String, String> body,
});
@POST('/api/chosen_plans/{id}/apply_promo')
Future<PromoApplyResponse> applyPromoCode({
@Path() required int id,
@Body() required Map<String, String> body,
});
@DELETE('/api/chosen_plans/{id}/remove_promo_code')
Future<PromoRemoveResponse> removePromoCode({
@Path() required int id,
});
@GET('/api/consultation/resolve/order_id_to_conversation/{orderId}')
Future<OrderIdToConversationModel> resolveOrderIdToConversation({
@Path() required String orderId,

View File

@@ -116,9 +116,7 @@ class _PlanWidgetState extends State<PlanWidget> {
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,
),
),

View File

@@ -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<PromoCodeModel> promoCodes,
}) = _PromoAppliedListResponse;
factory PromoAppliedListResponse.fromJson(Map<String, dynamic> json) =>
_$PromoAppliedListResponseFromJson(json);
}

View File

@@ -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<String, dynamic> json) =>
_$PromoApplyResponseFromJson(json);
}

View File

@@ -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<String, dynamic> json) =>
_$PromoCodeModelFromJson(json);
}

View File

@@ -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<String, dynamic> json) =>
_$PromoRemoveResponseFromJson(json);
}

View File

@@ -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<String, dynamic> json) =>
_$PromoValidateResponseFromJson(json);
}

View File

@@ -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<void> showConsultationPaymentFlow(
BuildContext context, {
required int planId,
Future<bool?> Function(BuildContext)? showGate,
Future<PromoCodeResult?> Function(BuildContext, {Future<PromoValidation> Function(String)? onValidate})? showForm,
Future<PromoCodeResult?> Function(
BuildContext, {
Future<PromoValidation> Function(String)? onValidate,
Future<bool> Function()? onRemove,
PromoCodeModel? initialPromo,
})? showForm,
Future<void> Function(BuildContext, {required int planId})? showSbp,
Future<PromoValidateResponse> Function({
required int planId,
required String promoCode,
})? validatePromo,
Future<PromoApplyResponse> Function({
required int planId,
required String promoCode,
})? applyPromo,
Future<PromoCodeModel?> Function(int planId)? listApplied,
Future<bool> 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<void> 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<bool?> _showGate(BuildContext context) {
Future<PromoCodeResult?> _showForm(
BuildContext context, {
Future<PromoValidation> Function(String)? onValidate,
Future<bool> Function()? onRemove,
PromoCodeModel? initialPromo,
}) {
return ItvioPromoCodeModal.show(context, onValidate: onValidate);
return ItvioPromoCodeModal.show(
context,
onValidate: onValidate,
onRemove: onRemove,
initialPromo: initialPromo,
);
}
Future<void> _showSbp(BuildContext context, {required int planId}) {
return showSbpBanksBottomSheet(context, planId: planId);
}
Future<PromoValidateResponse> _defaultValidatePromo({
required int planId,
required String promoCode,
}) {
return sl<PaymentRepository>().validatePromoCode(
planId: planId,
promoCode: promoCode,
);
}
Future<PromoApplyResponse> _defaultApplyPromo({
required int planId,
required String promoCode,
}) {
return sl<PaymentRepository>().applyPromoCode(
planId: planId,
promoCode: promoCode,
);
}
Future<PromoCodeModel?> _defaultFindApplied(int planId) {
return sl<PaymentRepository>().findAppliedPromoForPlan(planId);
}
Future<bool> _defaultRemovePromo(int planId) async {
try {
final response = await sl<PaymentRepository>().removePromoCode(planId: planId);
if (response.success) return true;
showErrorToast(response.error ?? 'Не удалось убрать промокод.');
return false;
} catch (_) {
showErrorToast('Не удалось убрать промокод. Попробуйте позже');
return false;
}
}
Future<PromoValidation> _mapValidateResponse(
Future<PromoValidateResponse> 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<bool> _tryApplyPromo({
required Future<PromoApplyResponse> 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;
}
}

View File

@@ -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<PromoValidation> Function(String code)? onValidate;
final Future<bool> Function()? onRemove;
final PromoCodeModel? initialPromo;
static Future<PromoCodeResult?> show(
BuildContext context, {
Future<PromoValidation> Function(String code)? onValidate,
Future<bool> Function()? onRemove,
PromoCodeModel? initialPromo,
}) {
return showModalBottomSheet<PromoCodeResult>(
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<ItvioPromoCodeModal> createState() => _ItvioPromoCodeModalState();
}
enum _ModalState { input, loading }
enum _ModalState { input, loading, applied }
class _ItvioPromoCodeModalState extends State<ItvioPromoCodeModal> {
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<ItvioPromoCodeModal> {
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<void> _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<ItvioPromoCodeModal> {
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<ItvioPromoCodeModal> {
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<ItvioPromoCodeModal> {
width: 2,
color: hasError
? appTheme.red
: (_focusNode.hasFocus
: (_focusNode.hasFocus && !_isApplied
? appTheme.blueA400
: Colors.transparent),
),
@@ -182,14 +312,16 @@ class _ItvioPromoCodeModalState extends State<ItvioPromoCodeModal> {
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<ItvioPromoCodeModal> {
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<ItvioPromoCodeModal> {
);
}
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<ItvioPromoCodeModal> {
);
}
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,

View File

@@ -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<PromoAppliedListResponse> listAppliedPromoCodes() {
return chatService.listAppliedPromoCodes();
}
/// Applied-промокод для конкретного chosen_plan, либо null (нет / ошибка сети).
Future<PromoCodeModel?> 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<PromoValidateResponse> validatePromoCode({
required int planId,
required String promoCode,
}) {
return chatService.validatePromoCode(
id: planId,
body: {'promo_code': promoCode},
);
}
/// Применяет промокод к chosen_plan. При HTTP 422 возвращает тело ответа.
Future<PromoApplyResponse> 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<String, dynamic>) {
return PromoApplyResponse.fromJson(data);
}
rethrow;
}
}
/// Удаляет применённый промокод (idempotent).
Future<PromoRemoveResponse> 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<String, dynamic>) {
return PromoRemoveResponse.fromJson(data);
}
rethrow;
}
}
}

View File

@@ -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<String, Function> _notInlinedMessages(_) => <String, Function>{
@@ -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(
"Вы уверены, что хотите отменить заказ?",
),

View File

@@ -20,6 +20,9 @@ typedef String MessageIfAbsent(String messageStr, List<dynamic> args);
class MessageLookup extends MessageLookupByLibrary {
String get localeName => 'ru';
static String m3(percent, amount) =>
"Скидка ${percent}% — вы экономите ${amount} ₽";
final messages = _notInlinedMessages(_notInlinedMessages);
static Map<String, Function> _notInlinedMessages(_) => <String, Function>{
"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("Опубликовать"),

View File

@@ -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(

View File

@@ -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",

View File

@@ -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к консультации",

View File

@@ -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<bool?> mockGate(BuildContext context) async {
gateCalled = true;
@@ -37,8 +49,27 @@ void main() {
Future<PromoCodeResult?> mockForm(
BuildContext context, {
Future<PromoValidation> Function(String)? onValidate,
Future<bool> 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<PromoValidateResponse> mockValidate({
required int planId,
required String promoCode,
}) async {
validateCalls++;
lastValidatedCode = promoCode;
return validateResponse;
}
Future<PromoApplyResponse> mockApply({
required int planId,
required String promoCode,
}) async {
applyCalls++;
lastAppliedCode = promoCode;
if (applyThrows) {
throw Exception('network');
}
return applyResponse;
}
Future<PromoCodeModel?> mockListApplied(int planId) async {
return listAppliedResult;
}
Future<bool> 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<void> 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);
});
});
}

View File

@@ -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<PromoCodeModel>());
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': <dynamic>[],
});
expect(response.promoCodes, isEmpty);
});
});
}

View File

@@ -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<PromoValidation> Function(String)? onValidate,
Future<bool> 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);
});
});
}