import 'package:eatvio_app/core/theme/custom_text_style.dart'; import 'package:eatvio_app/core/theme/theme_helper.dart'; import 'package:eatvio_app/core/widgets/bottom_sheet_handler_bar.dart'; import 'package:eatvio_app/features/payment/models/promo_code_model.dart'; import 'package:eatvio_app/generated/l10n.dart'; import 'package:flutter/material.dart'; /// Результат работы модалки промокода. class PromoCodeResult { const PromoCodeResult({ this.code, this.discountPercent, this.discountAmountRub, this.originalAmountRub, this.finalAmountRub, this.alreadyApplied = false, }); /// Применённый промокод или null, если пользователь продолжил без него. final String? code; final int? discountPercent; final double? discountAmountRub; final double? originalAmountRub; final double? finalAmountRub; /// true — промокод уже на сервере (rehydrate Continue), apply не нужен. final bool alreadyApplied; bool get hasPromoCode => code != null; } /// Ответ проверки промокода. class PromoValidation { const PromoValidation.success({ this.discountPercent, this.discountAmountRub, this.originalAmountRub, this.finalAmountRub, }) : error = null; const PromoValidation.failure(this.error) : discountPercent = null, discountAmountRub = null, originalAmountRub = null, finalAmountRub = null; final String? error; final int? discountPercent; final double? discountAmountRub; final double? originalAmountRub; final double? finalAmountRub; bool get isValid => error == null; } /// Модальное окно ввода / просмотра применённого промокода. class ItvioPromoCodeModal extends StatefulWidget { const ItvioPromoCodeModal({ super.key, this.onValidate, this.onRemove, this.initialPromo, }); final Future Function(String code)? onValidate; final Future Function()? onRemove; final PromoCodeModel? initialPromo; static Future show( BuildContext context, { Future Function(String code)? onValidate, Future Function()? onRemove, PromoCodeModel? initialPromo, }) { return showModalBottomSheet( context: context, isScrollControlled: true, useSafeArea: true, backgroundColor: Colors.transparent, builder: (_) => ItvioPromoCodeModal( onValidate: onValidate, onRemove: onRemove, initialPromo: initialPromo, ), ); } @override State createState() => _ItvioPromoCodeModalState(); } enum _ModalState { input, loading, applied } class _ItvioPromoCodeModalState extends State { final _controller = TextEditingController(); final _focusNode = FocusNode(); late _ModalState _state; String? _errorText; int? _discountPercent; double? _discountAmountRub; String? _appliedCode; @override void initState() { super.initState(); final initial = widget.initialPromo; if (initial != null) { _state = _ModalState.applied; _appliedCode = initial.promoCode; _discountPercent = initial.discountPercent; _discountAmountRub = initial.discountAmountRub; _controller.text = initial.promoCode; } else { _state = _ModalState.input; } } @override void dispose() { _controller.dispose(); _focusNode.dispose(); super.dispose(); } Future _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, 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 _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() { Navigator.of(context).pop(const PromoCodeResult()); } bool get _isLoading => _state == _ModalState.loading; /// Applied UI while loading remove (state=loading, but promo still shown). bool get _isApplied => _appliedCode != null && _state != _ModalState.input; @override Widget build(BuildContext context) { final bottomInset = MediaQuery.of(context).viewInsets.bottom; 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(s), if (_isApplied) ...[ const SizedBox(height: 6), Text( s.promo_applied_subtitle, style: const TextStyle(fontSize: 14, color: Color(0xFF6B7684)), ), ], const SizedBox(height: 16), Text( s.promo_code, style: const TextStyle( fontSize: 13, color: Color(0xFF6B7684), ), ), const SizedBox(height: 8), _buildField(s), const SizedBox(height: 8), _buildHint(s), const SizedBox(height: 12), if (_isApplied) ...[ _buildContinueButton(s), const SizedBox(height: 6), _buildRemoveButton(s), ] else ...[ _buildApplyButton(s), const SizedBox(height: 6), _buildSkipButton(s), ], ], ), ), ); } Widget _buildHeader(S s) { return Text( _isApplied ? s.promo_applied_title : s.have_promo_code_form, style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black), ); } Widget _buildField(S s) { final hasError = _errorText != null && !_isApplied; 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 && !_isApplied ? appTheme.blueA400 : Colors.transparent), ), ), child: Row( children: [ Expanded( child: TextField( controller: _controller, focusNode: _focusNode, enabled: !_isLoading && !_isApplied, textCapitalization: TextCapitalization.characters, onChanged: (_) { if (_errorText != null) { setState(() => _errorText = null); } }, onSubmitted: (_) { if (!_isApplied) _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: TextStyle( fontSize: 15, color: _isApplied ? const Color(0xFF6B7684) : const Color(0xFF24303F), ), ), ), ], ), ); } Widget _buildHint(S s) { final String text; final Color color; if (_errorText != null) { text = _errorText!; color = appTheme.red; } else if (_isApplied && _discountPercent != null) { final amount = (_discountAmountRub ?? 0).toStringAsFixed(0); text = s.promo_discount_hint(_discountPercent!, amount); color = const Color(0xFF27A06B); } else { text = ''; color = Colors.transparent; } 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 _buildContinueButton(S s) { return SizedBox( height: 50, child: FilledButton( onPressed: _isLoading ? null : _onContinueApplied, style: FilledButton.styleFrom( backgroundColor: appTheme.blueA400, foregroundColor: appTheme.whiteA700, disabledBackgroundColor: appTheme.blueA400.withValues(alpha: 0.55), shape: const StadiumBorder(), textStyle: const TextStyle( fontSize: 15, fontWeight: FontWeight.w600, ), ), child: Text(s.continue_), ), ); } Widget _buildRemoveButton(S s) { return SizedBox( height: 46, child: TextButton( onPressed: _isLoading ? null : _onRemove, style: TextButton.styleFrom( foregroundColor: appTheme.blueA400, shape: const StadiumBorder(), textStyle: const TextStyle(fontSize: 14), ), child: _isLoading ? SizedBox( width: 20, height: 20, child: CircularProgressIndicator( strokeWidth: 2.5, color: appTheme.blueA400, ), ) : Text(s.promo_remove), ), ); } Widget _buildSkipButton(S s) { return SizedBox( height: 46, child: TextButton( onPressed: _isLoading ? null : _onSkip, style: TextButton.styleFrom( foregroundColor: appTheme.blueA400, shape: const StadiumBorder(), textStyle: const TextStyle(fontSize: 14), ), child: Text(s.skip), ), ); } }