feat(payment, chat): promo code mocked BottomSheet added

This commit is contained in:
Anatoliy
2026-07-14 15:50:44 +03:00
parent e94681266a
commit b064c7016e
12 changed files with 827 additions and 4 deletions

View File

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

View 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);
}

View File

@@ -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),
);
}
}

View 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),
),
);
}
}

View File

@@ -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(
"Если вас что-то не устроило, вы можете подать жалобу",
),

View File

@@ -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(
"Ваше артериальное давление?",

View File

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

View File

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

View File

@@ -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": "Промокод не найден или истёк"
}