forked from eatvio/eatvio_app
464 lines
13 KiB
Dart
464 lines
13 KiB
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/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),
|
||
),
|
||
);
|
||
}
|
||
}
|