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/
|
backend/
|
||||||
.fvm/
|
.fvm/
|
||||||
.fvmrc
|
.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/order_id_to_conversation_model.dart';
|
||||||
import 'package:eatvio_app/features/chat/models/payment_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/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:eatvio_app/features/plans/models/chosen_plan_model.dart';
|
||||||
import 'package:retrofit/retrofit.dart';
|
import 'package:retrofit/retrofit.dart';
|
||||||
|
|
||||||
@@ -69,6 +73,26 @@ abstract class ChatService {
|
|||||||
@Body() required Map<String, String> body,
|
@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}')
|
@GET('/api/consultation/resolve/order_id_to_conversation/{orderId}')
|
||||||
Future<OrderIdToConversationModel> resolveOrderIdToConversation({
|
Future<OrderIdToConversationModel> resolveOrderIdToConversation({
|
||||||
@Path() required String orderId,
|
@Path() required String orderId,
|
||||||
|
|||||||
@@ -116,9 +116,7 @@ class _PlanWidgetState extends State<PlanWidget> {
|
|||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
child: MarkdownHtmlWidget(
|
child: MarkdownHtmlWidget(
|
||||||
markdownText: widget.plan.details.length > 200
|
markdownText: widget.plan.details.length > 200 ? '${widget.plan.details.substring(0, 200)}...' : widget.plan.details,
|
||||||
? '${widget.plan.details.substring(0, 200)}...'
|
|
||||||
: widget.plan.details,
|
|
||||||
textColor: appTheme.black,
|
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_gate_bottom_sheet.dart';
|
||||||
import 'package:eatvio_app/features/payment/presentation/widgets/promo_code_modal.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/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';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
/// Оркестрирует флоу оплаты консультации:
|
/// Оркестрирует флоу оплаты консультации:
|
||||||
/// гейт «У вас есть промокод?» → (Да → форма промокода) → SbpBanks.
|
/// applied rehydrate → (или гейт → форма) → apply при необходимости → SbpBanks.
|
||||||
Future<void> showConsultationPaymentFlow(
|
Future<void> showConsultationPaymentFlow(
|
||||||
BuildContext context, {
|
BuildContext context, {
|
||||||
required int planId,
|
required int planId,
|
||||||
Future<bool?> Function(BuildContext)? showGate,
|
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<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 {
|
}) async {
|
||||||
final gate = showGate ?? _showGate;
|
final gate = showGate ?? _showGate;
|
||||||
final form = showForm ?? _showForm;
|
final form = showForm ?? _showForm;
|
||||||
final sbp = showSbp ?? _showSbp;
|
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);
|
final gateChoice = await gate(context);
|
||||||
if (gateChoice == null || !context.mounted) return;
|
if (gateChoice == null || !context.mounted) return;
|
||||||
@@ -24,9 +75,20 @@ Future<void> showConsultationPaymentFlow(
|
|||||||
if (gateChoice) {
|
if (gateChoice) {
|
||||||
final formResult = await form(
|
final formResult = await form(
|
||||||
context,
|
context,
|
||||||
onValidate: (_) async => PromoValidation.failure(s.promo_invalid),
|
onValidate: (code) => _mapValidateResponse(
|
||||||
|
validate(planId: planId, promoCode: code),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
if (formResult == null || !context.mounted) return;
|
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;
|
if (!context.mounted) return;
|
||||||
@@ -40,10 +102,96 @@ Future<bool?> _showGate(BuildContext context) {
|
|||||||
Future<PromoCodeResult?> _showForm(
|
Future<PromoCodeResult?> _showForm(
|
||||||
BuildContext context, {
|
BuildContext context, {
|
||||||
Future<PromoValidation> Function(String)? onValidate,
|
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}) {
|
Future<void> _showSbp(BuildContext context, {required int planId}) {
|
||||||
return showSbpBanksBottomSheet(context, planId: 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/custom_text_style.dart';
|
||||||
import 'package:eatvio_app/core/theme/theme_helper.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/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:eatvio_app/generated/l10n.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
/// Результат работы модалки промокода.
|
/// Результат работы модалки промокода.
|
||||||
class PromoCodeResult {
|
class PromoCodeResult {
|
||||||
const PromoCodeResult({this.code});
|
const PromoCodeResult({
|
||||||
|
this.code,
|
||||||
|
this.discountPercent,
|
||||||
|
this.discountAmountRub,
|
||||||
|
this.originalAmountRub,
|
||||||
|
this.finalAmountRub,
|
||||||
|
this.alreadyApplied = false,
|
||||||
|
});
|
||||||
|
|
||||||
/// Применённый промокод или null, если пользователь продолжил без него.
|
/// Применённый промокод или null, если пользователь продолжил без него.
|
||||||
final String? code;
|
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;
|
bool get hasPromoCode => code != null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ответ проверки промокода.
|
/// Ответ проверки промокода.
|
||||||
class PromoValidation {
|
class PromoValidation {
|
||||||
const PromoValidation.success() : error = null;
|
const PromoValidation.success({
|
||||||
const PromoValidation.failure(this.error);
|
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 String? error;
|
||||||
|
final int? discountPercent;
|
||||||
|
final double? discountAmountRub;
|
||||||
|
final double? originalAmountRub;
|
||||||
|
final double? finalAmountRub;
|
||||||
|
|
||||||
bool get isValid => error == null;
|
bool get isValid => error == null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Модальное окно ввода промокода.
|
/// Модальное окно ввода / просмотра применённого промокода.
|
||||||
class ItvioPromoCodeModal extends StatefulWidget {
|
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<PromoValidation> Function(String code)? onValidate;
|
||||||
|
final Future<bool> Function()? onRemove;
|
||||||
|
final PromoCodeModel? initialPromo;
|
||||||
|
|
||||||
static Future<PromoCodeResult?> show(
|
static Future<PromoCodeResult?> show(
|
||||||
BuildContext context, {
|
BuildContext context, {
|
||||||
Future<PromoValidation> Function(String code)? onValidate,
|
Future<PromoValidation> Function(String code)? onValidate,
|
||||||
|
Future<bool> Function()? onRemove,
|
||||||
|
PromoCodeModel? initialPromo,
|
||||||
}) {
|
}) {
|
||||||
return showModalBottomSheet<PromoCodeResult>(
|
return showModalBottomSheet<PromoCodeResult>(
|
||||||
context: context,
|
context: context,
|
||||||
isScrollControlled: true,
|
isScrollControlled: true,
|
||||||
useSafeArea: true,
|
useSafeArea: true,
|
||||||
backgroundColor: Colors.transparent,
|
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();
|
State<ItvioPromoCodeModal> createState() => _ItvioPromoCodeModalState();
|
||||||
}
|
}
|
||||||
|
|
||||||
enum _ModalState { input, loading }
|
enum _ModalState { input, loading, applied }
|
||||||
|
|
||||||
class _ItvioPromoCodeModalState extends State<ItvioPromoCodeModal> {
|
class _ItvioPromoCodeModalState extends State<ItvioPromoCodeModal> {
|
||||||
final _controller = TextEditingController();
|
final _controller = TextEditingController();
|
||||||
final _focusNode = FocusNode();
|
final _focusNode = FocusNode();
|
||||||
|
|
||||||
_ModalState _state = _ModalState.input;
|
late _ModalState _state;
|
||||||
String? _errorText;
|
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
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
@@ -98,7 +158,62 @@ class _ItvioPromoCodeModalState extends State<ItvioPromoCodeModal> {
|
|||||||
return;
|
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() {
|
void _onSkip() {
|
||||||
@@ -107,6 +222,10 @@ class _ItvioPromoCodeModalState extends State<ItvioPromoCodeModal> {
|
|||||||
|
|
||||||
bool get _isLoading => _state == _ModalState.loading;
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final bottomInset = MediaQuery.of(context).viewInsets.bottom;
|
final bottomInset = MediaQuery.of(context).viewInsets.bottom;
|
||||||
@@ -127,40 +246,51 @@ class _ItvioPromoCodeModalState extends State<ItvioPromoCodeModal> {
|
|||||||
children: [
|
children: [
|
||||||
const Center(child: BottomSheetHandlerBar()),
|
const Center(child: BottomSheetHandlerBar()),
|
||||||
const SizedBox(height: 16),
|
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 SizedBox(height: 16),
|
||||||
const Text(
|
Text(
|
||||||
'Промокод',
|
s.promo_code,
|
||||||
style: TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
color: Color(0xFF6B7684),
|
color: Color(0xFF6B7684),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
_buildField(),
|
_buildField(s),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
_buildHint(),
|
_buildHint(s),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_buildApplyButton(s),
|
if (_isApplied) ...[
|
||||||
const SizedBox(height: 6),
|
_buildContinueButton(s),
|
||||||
_buildSkipButton(s),
|
const SizedBox(height: 6),
|
||||||
|
_buildRemoveButton(s),
|
||||||
|
] else ...[
|
||||||
|
_buildApplyButton(s),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
_buildSkipButton(s),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildHeader() {
|
Widget _buildHeader(S s) {
|
||||||
final s = S.of(context);
|
|
||||||
return Text(
|
return Text(
|
||||||
s.have_promo_code_form,
|
_isApplied ? s.promo_applied_title : s.have_promo_code_form,
|
||||||
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
|
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildField() {
|
Widget _buildField(S s) {
|
||||||
final hasError = _errorText != null;
|
final hasError = _errorText != null && !_isApplied;
|
||||||
final s = S.of(context);
|
|
||||||
|
|
||||||
return AnimatedContainer(
|
return AnimatedContainer(
|
||||||
duration: const Duration(milliseconds: 150),
|
duration: const Duration(milliseconds: 150),
|
||||||
@@ -171,7 +301,7 @@ class _ItvioPromoCodeModalState extends State<ItvioPromoCodeModal> {
|
|||||||
width: 2,
|
width: 2,
|
||||||
color: hasError
|
color: hasError
|
||||||
? appTheme.red
|
? appTheme.red
|
||||||
: (_focusNode.hasFocus
|
: (_focusNode.hasFocus && !_isApplied
|
||||||
? appTheme.blueA400
|
? appTheme.blueA400
|
||||||
: Colors.transparent),
|
: Colors.transparent),
|
||||||
),
|
),
|
||||||
@@ -182,14 +312,16 @@ class _ItvioPromoCodeModalState extends State<ItvioPromoCodeModal> {
|
|||||||
child: TextField(
|
child: TextField(
|
||||||
controller: _controller,
|
controller: _controller,
|
||||||
focusNode: _focusNode,
|
focusNode: _focusNode,
|
||||||
enabled: !_isLoading,
|
enabled: !_isLoading && !_isApplied,
|
||||||
textCapitalization: TextCapitalization.characters,
|
textCapitalization: TextCapitalization.characters,
|
||||||
onChanged: (_) {
|
onChanged: (_) {
|
||||||
if (_errorText != null) {
|
if (_errorText != null) {
|
||||||
setState(() => _errorText = null);
|
setState(() => _errorText = null);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onSubmitted: (_) => _apply(),
|
onSubmitted: (_) {
|
||||||
|
if (!_isApplied) _apply();
|
||||||
|
},
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: s.enter_promo_code,
|
hintText: s.enter_promo_code,
|
||||||
hintStyle: const TextStyle(
|
hintStyle: const TextStyle(
|
||||||
@@ -202,9 +334,11 @@ class _ItvioPromoCodeModalState extends State<ItvioPromoCodeModal> {
|
|||||||
vertical: 14,
|
vertical: 14,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 15,
|
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 String text;
|
||||||
final Color color;
|
final Color color;
|
||||||
|
|
||||||
if (_errorText != null) {
|
if (_errorText != null) {
|
||||||
text = _errorText!;
|
text = _errorText!;
|
||||||
color = appTheme.red;
|
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 {
|
} else {
|
||||||
text = '';
|
text = '';
|
||||||
color = Colors.transparent;
|
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) {
|
Widget _buildSkipButton(S s) {
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
height: 46,
|
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/models/payment_model.dart';
|
||||||
import 'package:eatvio_app/features/chat/services/chat_service.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 / СБП.
|
/// Репозиторий оплаты консультаций (chosen_plan) через Alfa Bank / СБП.
|
||||||
class PaymentRepository {
|
class PaymentRepository {
|
||||||
@@ -15,4 +21,67 @@ class PaymentRepository {
|
|||||||
body: const {'payment_provider': 'alfa-bank'},
|
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) =>
|
static String m2(planName, name) =>
|
||||||
"План «${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}";
|
"Завтра начинается план «${planName}» с пользователем ${name}";
|
||||||
|
|
||||||
static String m5(planName) =>
|
static String m6(planName) =>
|
||||||
"Завтра начнется прохождение плана «${planName}»";
|
"Завтра начнется прохождение плана «${planName}»";
|
||||||
|
|
||||||
static String m6(name, planName) =>
|
static String m7(name, planName) =>
|
||||||
"Пользователь ${name} отменил подписку на план «${planName}»";
|
"Пользователь ${name} отменил подписку на план «${planName}»";
|
||||||
|
|
||||||
static String m7(name, planName) =>
|
static String m8(name, planName) =>
|
||||||
"Пользователь ${name} оплатил план «${planName}»";
|
"Пользователь ${name} оплатил план «${planName}»";
|
||||||
|
|
||||||
static String m8(name, planName) =>
|
static String m9(name, planName) =>
|
||||||
"Пользователь ${name} подписался на план «${planName}»";
|
"Пользователь ${name} подписался на план «${planName}»";
|
||||||
|
|
||||||
static String m9(planName) => "Вы отменили подписку на план «${planName}»";
|
static String m10(planName) => "Вы отменили подписку на план «${planName}»";
|
||||||
|
|
||||||
final messages = _notInlinedMessages(_notInlinedMessages);
|
final messages = _notInlinedMessages(_notInlinedMessages);
|
||||||
static Map<String, Function> _notInlinedMessages(_) => <String, Function>{
|
static Map<String, Function> _notInlinedMessages(_) => <String, Function>{
|
||||||
@@ -715,10 +718,21 @@ class MessageLookup extends MessageLookupByLibrary {
|
|||||||
"Разница в ценах, это комиссия Итвио",
|
"Разница в ценах, это комиссия Итвио",
|
||||||
),
|
),
|
||||||
"profile": MessageLookupByLibrary.simpleMessage("Профиль"),
|
"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_code": MessageLookupByLibrary.simpleMessage("Promo code"),
|
||||||
|
"promo_discount_hint": m3,
|
||||||
"promo_invalid": MessageLookupByLibrary.simpleMessage(
|
"promo_invalid": MessageLookupByLibrary.simpleMessage(
|
||||||
"Promo code not found or expired",
|
"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("Проституция"),
|
"prostitution": MessageLookupByLibrary.simpleMessage("Проституция"),
|
||||||
"proteins": MessageLookupByLibrary.simpleMessage("Белки"),
|
"proteins": MessageLookupByLibrary.simpleMessage("Белки"),
|
||||||
"public_diet_state": MessageLookupByLibrary.simpleMessage("Публичная"),
|
"public_diet_state": MessageLookupByLibrary.simpleMessage("Публичная"),
|
||||||
@@ -809,7 +823,7 @@ class MessageLookup extends MessageLookupByLibrary {
|
|||||||
"step_by_step_photo_recipe": MessageLookupByLibrary.simpleMessage(
|
"step_by_step_photo_recipe": MessageLookupByLibrary.simpleMessage(
|
||||||
"Пошаговый фоторецепт",
|
"Пошаговый фоторецепт",
|
||||||
),
|
),
|
||||||
"step_widget": m3,
|
"step_widget": m4,
|
||||||
"successfully_published": MessageLookupByLibrary.simpleMessage(
|
"successfully_published": MessageLookupByLibrary.simpleMessage(
|
||||||
"успешно опубликован",
|
"успешно опубликован",
|
||||||
),
|
),
|
||||||
@@ -834,8 +848,8 @@ class MessageLookup extends MessageLookupByLibrary {
|
|||||||
"toOffer": MessageLookupByLibrary.simpleMessage("Предложить"),
|
"toOffer": MessageLookupByLibrary.simpleMessage("Предложить"),
|
||||||
"to_draft": MessageLookupByLibrary.simpleMessage("В черновик"),
|
"to_draft": MessageLookupByLibrary.simpleMessage("В черновик"),
|
||||||
"to_order": MessageLookupByLibrary.simpleMessage("Заказать"),
|
"to_order": MessageLookupByLibrary.simpleMessage("Заказать"),
|
||||||
"tommorowPlanWithUserWillStart": m4,
|
"tommorowPlanWithUserWillStart": m5,
|
||||||
"tomorrowYourPlanWillStart": m5,
|
"tomorrowYourPlanWillStart": m6,
|
||||||
"try_again": MessageLookupByLibrary.simpleMessage("Попробовать снова"),
|
"try_again": MessageLookupByLibrary.simpleMessage("Попробовать снова"),
|
||||||
"try_again_2": MessageLookupByLibrary.simpleMessage("Попробуйте ещё раз"),
|
"try_again_2": MessageLookupByLibrary.simpleMessage("Попробуйте ещё раз"),
|
||||||
"typeAmoutOfReturn": MessageLookupByLibrary.simpleMessage(
|
"typeAmoutOfReturn": MessageLookupByLibrary.simpleMessage(
|
||||||
@@ -869,7 +883,7 @@ class MessageLookup extends MessageLookupByLibrary {
|
|||||||
"Верхние показатели давления:",
|
"Верхние показатели давления:",
|
||||||
),
|
),
|
||||||
"upper": MessageLookupByLibrary.simpleMessage("Верхнее"),
|
"upper": MessageLookupByLibrary.simpleMessage("Верхнее"),
|
||||||
"userCanceldThePlanSubscription": m6,
|
"userCanceldThePlanSubscription": m7,
|
||||||
"userConfirmedPlanCompletion": MessageLookupByLibrary.simpleMessage(
|
"userConfirmedPlanCompletion": MessageLookupByLibrary.simpleMessage(
|
||||||
"Пользователь подтвердил прохождения плана. В ближайшее время Вы получите",
|
"Пользователь подтвердил прохождения плана. В ближайшее время Вы получите",
|
||||||
),
|
),
|
||||||
@@ -877,8 +891,8 @@ class MessageLookup extends MessageLookupByLibrary {
|
|||||||
MessageLookupByLibrary.simpleMessage(
|
MessageLookupByLibrary.simpleMessage(
|
||||||
"Пользователь подтвердил прохождения плана.",
|
"Пользователь подтвердил прохождения плана.",
|
||||||
),
|
),
|
||||||
"userPaidThePlan": m7,
|
"userPaidThePlan": m8,
|
||||||
"userSubscribedToPlan": m8,
|
"userSubscribedToPlan": m9,
|
||||||
"user_authorization": MessageLookupByLibrary.simpleMessage(
|
"user_authorization": MessageLookupByLibrary.simpleMessage(
|
||||||
"Аутентификация",
|
"Аутентификация",
|
||||||
),
|
),
|
||||||
@@ -1008,7 +1022,7 @@ class MessageLookup extends MessageLookupByLibrary {
|
|||||||
"youCanComplain": MessageLookupByLibrary.simpleMessage(
|
"youCanComplain": MessageLookupByLibrary.simpleMessage(
|
||||||
"Если вас что-то не устроило, вы можете подать жалобу",
|
"Если вас что-то не устроило, вы можете подать жалобу",
|
||||||
),
|
),
|
||||||
"youCanceledSubscriptionToPlan": m9,
|
"youCanceledSubscriptionToPlan": m10,
|
||||||
"youShureYouWantToCancelOrder": MessageLookupByLibrary.simpleMessage(
|
"youShureYouWantToCancelOrder": MessageLookupByLibrary.simpleMessage(
|
||||||
"Вы уверены, что хотите отменить заказ?",
|
"Вы уверены, что хотите отменить заказ?",
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ typedef String MessageIfAbsent(String messageStr, List<dynamic> args);
|
|||||||
class MessageLookup extends MessageLookupByLibrary {
|
class MessageLookup extends MessageLookupByLibrary {
|
||||||
String get localeName => 'ru';
|
String get localeName => 'ru';
|
||||||
|
|
||||||
|
static String m3(percent, amount) =>
|
||||||
|
"Скидка ${percent}% — вы экономите ${amount} ₽";
|
||||||
|
|
||||||
final messages = _notInlinedMessages(_notInlinedMessages);
|
final messages = _notInlinedMessages(_notInlinedMessages);
|
||||||
static Map<String, Function> _notInlinedMessages(_) => <String, Function>{
|
static Map<String, Function> _notInlinedMessages(_) => <String, Function>{
|
||||||
"Moscow": MessageLookupByLibrary.simpleMessage("Москва"),
|
"Moscow": MessageLookupByLibrary.simpleMessage("Москва"),
|
||||||
@@ -322,10 +325,21 @@ class MessageLookup extends MessageLookupByLibrary {
|
|||||||
"Разница в ценах, это комиссия Итвио",
|
"Разница в ценах, это комиссия Итвио",
|
||||||
),
|
),
|
||||||
"profile": MessageLookupByLibrary.simpleMessage("Профиль"),
|
"profile": MessageLookupByLibrary.simpleMessage("Профиль"),
|
||||||
|
"promo_applied_subtitle": MessageLookupByLibrary.simpleMessage(
|
||||||
|
"Скидка учтётся при оплате",
|
||||||
|
),
|
||||||
|
"promo_applied_title": MessageLookupByLibrary.simpleMessage(
|
||||||
|
"Промокод применён!",
|
||||||
|
),
|
||||||
"promo_code": MessageLookupByLibrary.simpleMessage("Промокод"),
|
"promo_code": MessageLookupByLibrary.simpleMessage("Промокод"),
|
||||||
|
"promo_discount_hint": m3,
|
||||||
"promo_invalid": MessageLookupByLibrary.simpleMessage(
|
"promo_invalid": MessageLookupByLibrary.simpleMessage(
|
||||||
"Промокод не найден или истёк",
|
"Промокод не найден или истёк",
|
||||||
),
|
),
|
||||||
|
"promo_remove": MessageLookupByLibrary.simpleMessage("Убрать промокод"),
|
||||||
|
"promo_remove_failed": MessageLookupByLibrary.simpleMessage(
|
||||||
|
"Не удалось убрать промокод. Попробуйте позже",
|
||||||
|
),
|
||||||
"proteins": MessageLookupByLibrary.simpleMessage("Белки"),
|
"proteins": MessageLookupByLibrary.simpleMessage("Белки"),
|
||||||
"public_diet_state": MessageLookupByLibrary.simpleMessage("Публичная"),
|
"public_diet_state": MessageLookupByLibrary.simpleMessage("Публичная"),
|
||||||
"publish": 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…`
|
/// `Loading…`
|
||||||
String get video_call_screen_loading_title {
|
String get video_call_screen_loading_title {
|
||||||
return Intl.message(
|
return Intl.message(
|
||||||
|
|||||||
@@ -502,6 +502,17 @@
|
|||||||
"promo_code": "Promo code",
|
"promo_code": "Promo code",
|
||||||
"enter_promo_code": "Enter promo code",
|
"enter_promo_code": "Enter promo code",
|
||||||
"promo_invalid": "Promo code not found or expired",
|
"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_loading_title": "Loading…",
|
||||||
"video_call_screen_connecting_title": "Connecting to\nthe consultation",
|
"video_call_screen_connecting_title": "Connecting to\nthe consultation",
|
||||||
"video_call_screen_connecting_subtitle": "This may take a few seconds",
|
"video_call_screen_connecting_subtitle": "This may take a few seconds",
|
||||||
|
|||||||
@@ -246,6 +246,17 @@
|
|||||||
"promo_code": "Промокод",
|
"promo_code": "Промокод",
|
||||||
"enter_promo_code": "Введите промокод",
|
"enter_promo_code": "Введите промокод",
|
||||||
"promo_invalid": "Промокод не найден или истёк",
|
"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_permission_microphone_description": "Чтобы вас слышали во время видеозвонка, разрешите доступ к микрофону. При отказе можно включить доступ в настройках устройства.",
|
||||||
"video_call_screen_loading_title": "Загрузка…",
|
"video_call_screen_loading_title": "Загрузка…",
|
||||||
"video_call_screen_connecting_title": "Подключение\nк консультации",
|
"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/payment_flow.dart';
|
||||||
import 'package:eatvio_app/features/payment/presentation/widgets/promo_code_modal.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/material.dart';
|
||||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
@@ -26,8 +29,17 @@ void main() {
|
|||||||
late bool? gateResult;
|
late bool? gateResult;
|
||||||
late bool formCalled;
|
late bool formCalled;
|
||||||
late PromoCodeResult? formResult;
|
late PromoCodeResult? formResult;
|
||||||
|
late PromoCodeModel? formInitialPromo;
|
||||||
late bool sbpCalled;
|
late bool sbpCalled;
|
||||||
late int sbpPlanId;
|
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 {
|
Future<bool?> mockGate(BuildContext context) async {
|
||||||
gateCalled = true;
|
gateCalled = true;
|
||||||
@@ -37,8 +49,27 @@ void main() {
|
|||||||
Future<PromoCodeResult?> mockForm(
|
Future<PromoCodeResult?> mockForm(
|
||||||
BuildContext context, {
|
BuildContext context, {
|
||||||
Future<PromoValidation> Function(String)? onValidate,
|
Future<PromoValidation> Function(String)? onValidate,
|
||||||
|
Future<bool> Function()? onRemove,
|
||||||
|
PromoCodeModel? initialPromo,
|
||||||
}) async {
|
}) async {
|
||||||
formCalled = true;
|
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;
|
return formResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,31 +78,86 @@ void main() {
|
|||||||
sbpPlanId = planId;
|
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(() {
|
setUp(() {
|
||||||
gateCalled = false;
|
gateCalled = false;
|
||||||
gateResult = null;
|
gateResult = null;
|
||||||
formCalled = false;
|
formCalled = false;
|
||||||
formResult = null;
|
formResult = null;
|
||||||
|
formInitialPromo = null;
|
||||||
sbpCalled = false;
|
sbpCalled = false;
|
||||||
sbpPlanId = 0;
|
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 {
|
Future<void> runFlow(WidgetTester tester) {
|
||||||
gateResult = false;
|
return showConsultationPaymentFlow(
|
||||||
await tester.pumpWidget(createTestApp());
|
|
||||||
|
|
||||||
await showConsultationPaymentFlow(
|
|
||||||
tester.element(find.byType(SizedBox)),
|
tester.element(find.byType(SizedBox)),
|
||||||
planId: 42,
|
planId: 42,
|
||||||
showGate: mockGate,
|
showGate: mockGate,
|
||||||
showForm: mockForm,
|
showForm: mockForm,
|
||||||
showSbp: mockSbp,
|
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(gateCalled, isTrue);
|
||||||
expect(formCalled, isFalse);
|
expect(formCalled, isFalse);
|
||||||
expect(sbpCalled, isTrue);
|
expect(sbpCalled, isTrue);
|
||||||
expect(sbpPlanId, 42);
|
expect(sbpPlanId, 42);
|
||||||
|
expect(validateCalls, 0);
|
||||||
|
expect(applyCalls, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('Да → форма → Пропустить → вызывает SbpBanks', (tester) async {
|
testWidgets('Да → форма → Пропустить → вызывает SbpBanks', (tester) async {
|
||||||
@@ -79,18 +165,13 @@ void main() {
|
|||||||
formResult = const PromoCodeResult();
|
formResult = const PromoCodeResult();
|
||||||
await tester.pumpWidget(createTestApp());
|
await tester.pumpWidget(createTestApp());
|
||||||
|
|
||||||
await showConsultationPaymentFlow(
|
await runFlow(tester);
|
||||||
tester.element(find.byType(SizedBox)),
|
|
||||||
planId: 42,
|
|
||||||
showGate: mockGate,
|
|
||||||
showForm: mockForm,
|
|
||||||
showSbp: mockSbp,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(gateCalled, isTrue);
|
expect(gateCalled, isTrue);
|
||||||
expect(formCalled, isTrue);
|
expect(formCalled, isTrue);
|
||||||
expect(sbpCalled, isTrue);
|
expect(sbpCalled, isTrue);
|
||||||
expect(sbpPlanId, 42);
|
expect(sbpPlanId, 42);
|
||||||
|
expect(applyCalls, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('Да → форма отменена (null) → Sbp не вызывается', (tester) async {
|
testWidgets('Да → форма отменена (null) → Sbp не вызывается', (tester) async {
|
||||||
@@ -98,13 +179,7 @@ void main() {
|
|||||||
formResult = null;
|
formResult = null;
|
||||||
await tester.pumpWidget(createTestApp());
|
await tester.pumpWidget(createTestApp());
|
||||||
|
|
||||||
await showConsultationPaymentFlow(
|
await runFlow(tester);
|
||||||
tester.element(find.byType(SizedBox)),
|
|
||||||
planId: 42,
|
|
||||||
showGate: mockGate,
|
|
||||||
showForm: mockForm,
|
|
||||||
showSbp: mockSbp,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(gateCalled, isTrue);
|
expect(gateCalled, isTrue);
|
||||||
expect(formCalled, isTrue);
|
expect(formCalled, isTrue);
|
||||||
@@ -115,17 +190,121 @@ void main() {
|
|||||||
gateResult = null;
|
gateResult = null;
|
||||||
await tester.pumpWidget(createTestApp());
|
await tester.pumpWidget(createTestApp());
|
||||||
|
|
||||||
await showConsultationPaymentFlow(
|
await runFlow(tester);
|
||||||
tester.element(find.byType(SizedBox)),
|
|
||||||
planId: 42,
|
|
||||||
showGate: mockGate,
|
|
||||||
showForm: mockForm,
|
|
||||||
showSbp: mockSbp,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(gateCalled, isTrue);
|
expect(gateCalled, isTrue);
|
||||||
expect(formCalled, isFalse);
|
expect(formCalled, isFalse);
|
||||||
expect(sbpCalled, 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/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/features/payment/presentation/widgets/promo_code_modal.dart';
|
||||||
import 'package:eatvio_app/generated/l10n.dart';
|
import 'package:eatvio_app/generated/l10n.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
@@ -11,6 +12,8 @@ void main() {
|
|||||||
group('ItvioPromoCodeModal', () {
|
group('ItvioPromoCodeModal', () {
|
||||||
Widget createTestWidget({
|
Widget createTestWidget({
|
||||||
Future<PromoValidation> Function(String)? onValidate,
|
Future<PromoValidation> Function(String)? onValidate,
|
||||||
|
Future<bool> Function()? onRemove,
|
||||||
|
PromoCodeModel? initialPromo,
|
||||||
}) {
|
}) {
|
||||||
return MaterialApp(
|
return MaterialApp(
|
||||||
theme: ThemeHelper().themeData(),
|
theme: ThemeHelper().themeData(),
|
||||||
@@ -26,6 +29,8 @@ void main() {
|
|||||||
onPressed: () => ItvioPromoCodeModal.show(
|
onPressed: () => ItvioPromoCodeModal.show(
|
||||||
context,
|
context,
|
||||||
onValidate: onValidate,
|
onValidate: onValidate,
|
||||||
|
onRemove: onRemove,
|
||||||
|
initialPromo: initialPromo,
|
||||||
),
|
),
|
||||||
child: const Text('Open'),
|
child: const Text('Open'),
|
||||||
),
|
),
|
||||||
@@ -88,14 +93,84 @@ void main() {
|
|||||||
expect(find.text('Есть промокод?'), findsNothing);
|
expect(find.text('Есть промокод?'), findsNothing);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('отсутствует UI applied-состояния (Промокод применён)', (tester) async {
|
testWidgets('initialPromo показывает applied UI', (tester) async {
|
||||||
await tester.pumpWidget(createTestWidget());
|
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.tap(find.text('Open'));
|
||||||
await tester.pumpAndSettle();
|
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);
|
});
|
||||||
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