Files
eatvio_app/lib/features/payment/presentation/widgets/promo_code_modal.dart

464 lines
13 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<PromoValidation> Function(String code)? onValidate;
final Future<bool> Function()? onRemove;
final PromoCodeModel? initialPromo;
static Future<PromoCodeResult?> show(
BuildContext context, {
Future<PromoValidation> Function(String code)? onValidate,
Future<bool> Function()? onRemove,
PromoCodeModel? initialPromo,
}) {
return showModalBottomSheet<PromoCodeResult>(
context: context,
isScrollControlled: true,
useSafeArea: true,
backgroundColor: Colors.transparent,
builder: (_) => ItvioPromoCodeModal(
onValidate: onValidate,
onRemove: onRemove,
initialPromo: initialPromo,
),
);
}
@override
State<ItvioPromoCodeModal> createState() => _ItvioPromoCodeModalState();
}
enum _ModalState { input, loading, applied }
class _ItvioPromoCodeModalState extends State<ItvioPromoCodeModal> {
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<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,
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() {
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),
),
);
}
}