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