forked from eatvio/eatvio_app
feat(promo_codes): added promo codes handling to app
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -24,4 +24,5 @@ coverage/
|
||||
backend/
|
||||
.fvm/
|
||||
.fvmrc
|
||||
/eatvio
|
||||
/eatvio
|
||||
*.code-workspace
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
|
||||
15
lib/features/payment/models/promo_applied_list_response.dart
Normal file
15
lib/features/payment/models/promo_applied_list_response.dart
Normal 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);
|
||||
}
|
||||
21
lib/features/payment/models/promo_apply_response.dart
Normal file
21
lib/features/payment/models/promo_apply_response.dart
Normal 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);
|
||||
}
|
||||
22
lib/features/payment/models/promo_code_model.dart
Normal file
22
lib/features/payment/models/promo_code_model.dart
Normal 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);
|
||||
}
|
||||
15
lib/features/payment/models/promo_remove_response.dart
Normal file
15
lib/features/payment/models/promo_remove_response.dart
Normal 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);
|
||||
}
|
||||
20
lib/features/payment/models/promo_validate_response.dart
Normal file
20
lib/features/payment/models/promo_validate_response.dart
Normal 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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
"Вы уверены, что хотите отменить заказ?",
|
||||
),
|
||||
|
||||
@@ -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("Опубликовать"),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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к консультации",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
108
test/features/payment/models/promo_response_models_test.dart
Normal file
108
test/features/payment/models/promo_response_models_test.dart
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user