import 'package:decimal/decimal.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:intl/intl.dart'; import '../../../core/l10n/app_localizations.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/utils/top_toast.dart'; import '../../../data/models/finance/staking_config.dart'; import '../../../providers/auth_provider.dart'; import '../../../providers/market_provider.dart'; import '../../../providers/staking_provider.dart'; bool _financeIsDark(BuildContext context) => Theme.of(context).brightness == Brightness.dark; Color _financePageBg(BuildContext context) => _financeIsDark(context) ? AppColors.darkBg : Theme.of(context).colorScheme.surface; Color _financePrimaryText(BuildContext context) => _financeIsDark(context) ? Colors.white : Theme.of(context).colorScheme.onSurface; Color _financeSecondaryText(BuildContext context) => _financePrimaryText(context).withAlpha(160); Color _financeLabelText(BuildContext context) => _financePrimaryText(context).withAlpha(140); Color _financeFieldBg(BuildContext context) => _financePrimaryText(context).withAlpha(14); Color _financeFieldBorder(BuildContext context) => _financePrimaryText(context).withAlpha(40); class StakingScreen extends ConsumerStatefulWidget { const StakingScreen({ super.key, this.configId, this.showAppBar = true, }); final String? configId; final bool showAppBar; @override ConsumerState createState() => _StakingScreenState(); } class _StakingScreenState extends ConsumerState { final _amountController = TextEditingController(); @override void initState() { super.initState(); Future.microtask(() { ref.read(stakingProvider.notifier).init(configId: widget.configId); ref.read(marketProvider.notifier).loadSpotIfNeeded(); }); } @override void dispose() { _amountController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; final state = ref.watch(stakingProvider); final config = state.selectedConfig; final isLoggedIn = ref.watch(isLoggedInProvider); if (!state.isLoadingConfig && config == null) { final emptyBody = _EmptyState( message: state.errorMessage ?? l10n.financeConfigNotFound, ); if (!widget.showAppBar) { return ColoredBox( color: _financePageBg(context), child: emptyBody, ); } return Scaffold( backgroundColor: _financePageBg(context), appBar: AppBar( backgroundColor: Colors.transparent, foregroundColor: _financePrimaryText(context), elevation: 0, title: Text( l10n.financeIdoTitle, style: TextStyle(color: _financePrimaryText(context)), ), centerTitle: true, ), body: emptyBody, ); } final coinUnit = config?.coinUnit ?? 'IBIT'; final symbol = '${coinUnit.toUpperCase()}USDT'; final ticker = ref.watch(spotTickerProvider(symbol)); final unitPrice = _usdtPrice(coinUnit, ticker?.lastPrice ?? 0); final rulesText = config == null ? '' : _rulesText(l10n, config); final displayUnit = _displayCoinUnit(coinUnit); final unlockText = config == null ? '—' : _formatUnlockDate(_estimatedUnlockDate(config)); final pageBody = ListView( padding: const EdgeInsets.only(bottom: 32), children: [ _HeroSection( l10n: l10n, rulesText: rulesText, loadingRules: state.isLoadingConfig, ), Padding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( l10n.financeJoinPresale, style: TextStyle( color: _financePrimaryText(context), fontSize: 22, fontWeight: FontWeight.w600, ), ), const SizedBox(height: 24), _LabelRow( label: l10n.financeSubscribeQty, actionLabel: l10n.goSpotTrade, onAction: config == null ? null : () => _goToSpotTrade(config), ), const SizedBox(height: 12), _LabelRow(label: l10n.financeCorrespondingPrice), const SizedBox(height: 12), _AvailableMetaRow( l10n: l10n, amount: _fmtAmount(state.fundingAvailable), unit: displayUnit, loading: state.isRefreshingBalance, onFillAll: state.isRefreshingBalance ? null : () => _fillAllAmount(state.fundingAvailable), ), const SizedBox(height: 12), _UnitPriceMetaRow( l10n: l10n, unitPrice: unitPrice, ), const SizedBox(height: 12), _FieldInput( controller: _amountController, suffix: displayUnit, onChanged: (_) => setState(() {}), ), const SizedBox(height: 12), Row( children: [ Expanded( child: _FieldInput( readOnly: true, value: _priceUsdtText(unitPrice), suffix: 'USDT', ), ), const SizedBox(width: 12), _TransferButton( label: l10n.stakingTransfer, onPressed: () => _openSpotFundTransfer(isLoggedIn), ), ], ), const SizedBox(height: 32), Center( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 360), child: SizedBox( width: double.infinity, height: 48, child: ElevatedButton( onPressed: state.isSubmitting || config == null || state.isLoadingConfig ? null : () => _submitStake(isLoggedIn), style: ElevatedButton.styleFrom( backgroundColor: AppColors.brand, foregroundColor: Colors.black, disabledBackgroundColor: AppColors.brand.withAlpha(128), shape: const StadiumBorder(), elevation: 0, ), child: state.isSubmitting ? const SizedBox( width: 18, height: 18, child: CircularProgressIndicator( strokeWidth: 2, ), ) : Text( l10n.financeConfirmPresale, style: const TextStyle( fontSize: 16, fontWeight: FontWeight.w600, ), ), ), ), ), ), const SizedBox(height: 20), Text( state.isLoadingConfig ? l10n.financeEstimatedUnlockLine('—') : l10n.financeEstimatedUnlockLine(unlockText), textAlign: TextAlign.center, style: TextStyle( color: _financeSecondaryText(context), fontSize: 13, ), ), if (!isLoggedIn) ...[ const SizedBox(height: 12), Wrap( alignment: WrapAlignment.center, crossAxisAlignment: WrapCrossAlignment.center, children: [ Text( l10n.financeLoginToStake, style: TextStyle( color: _financeSecondaryText(context), fontSize: 12, ), ), GestureDetector( onTap: () => context.push( '/login?redirect=${Uri.encodeComponent('/finance/ido')}', ), child: Text( l10n.login, style: const TextStyle( color: AppColors.brand, fontSize: 12, ), ), ), ], ), ], ], ), ), ], ); if (!widget.showAppBar) { return ColoredBox( color: _financePageBg(context), child: pageBody, ); } return Scaffold( backgroundColor: _financePageBg(context), appBar: AppBar( backgroundColor: Colors.transparent, foregroundColor: _financePrimaryText(context), elevation: 0, title: Text( l10n.financeIdoTitle, style: TextStyle(color: _financePrimaryText(context)), ), centerTitle: true, ), body: pageBody, ); } Decimal _usdtPrice(String coinUnit, double wsPrice) { if (coinUnit.toUpperCase() == 'USDT') { return Decimal.one; } return Decimal.tryParse(wsPrice.toString()) ?? Decimal.zero; } String _priceUsdtText(Decimal unitPrice) { final qty = Decimal.tryParse(_amountController.text) ?? Decimal.zero; if (qty <= Decimal.zero || unitPrice <= Decimal.zero) { return '0.00'; } final total = qty * unitPrice; return NumberFormat('#,##0.00', 'en_US').format(total.toDouble()); } int _lockMonths(StakingConfig config) { final days = config.lockDays; return days <= 0 ? 1 : (days / 30).ceil().clamp(1, 999999); } int _releaseMonths(StakingConfig config) { if (config.releaseType != 1) { return 0; } final rate = double.tryParse(config.releaseRate) ?? 0; final periodDays = config.releasePeriod <= 0 ? 1 : config.releasePeriod; if (rate <= 0) { return (periodDays / 30).ceil().clamp(1, 999999); } final batches = (1 / rate).ceil(); final totalDays = batches * periodDays; return (totalDays / 30).ceil().clamp(1, 999999); } String _rulesText(AppLocalizations l10n, StakingConfig config) { final lock = _lockMonths(config); if (config.releaseType == 1 && _releaseMonths(config) > 0) { return l10n.financeIdoRuleBatch('$lock', '${_releaseMonths(config)}'); } return l10n.financeIdoRuleOnce('$lock'); } String _displayCoinUnit(String coinUnit) { final unit = coinUnit.trim(); if (unit.isEmpty) { return 'iBit'; } if (unit.toUpperCase() == 'IBIT') { return 'iBit'; } return unit; } String _fmtAmount(String value, {int maxFrac = 8}) { final parsed = double.tryParse(value); if (parsed == null || !parsed.isFinite) { return '0'; } if (parsed == 0) { return '0'; } return NumberFormat('#,##0.${'#' * maxFrac}', 'en_US').format(parsed); } DateTime _estimatedUnlockDate(StakingConfig config) { return DateTime.now().add(Duration(days: config.lockDays)); } String _formatUnlockDate(DateTime date) { String pad(int n) { return n.toString().padLeft(2, '0'); } return '${date.year}/${pad(date.month)}/${pad(date.day)} ' '${pad(date.hour)}:${pad(date.minute)}:${pad(date.second)}'; } void _fillAllAmount(String amount) { final parsed = double.tryParse(amount); if (parsed != null && parsed > 0) { _amountController.text = amount; setState(() {}); } } void _goToSpotTrade(StakingConfig config) { final symbol = '${config.coinUnit.toUpperCase()}USDT'; context.push('/market/spot/$symbol'); } Future _openSpotFundTransfer(bool isLoggedIn) async { if (!isLoggedIn) { context.push('/login?redirect=${Uri.encodeComponent('/finance/ido')}'); return; } await context.push( '/asset/transfer?from=SPOT&to=SPOT_TRADING&symbol=IBIT&preferSymbol=1&bridgeOnly=1', ); if (!mounted) { return; } await ref.read(stakingProvider.notifier).refreshBalances(); } Future _submitStake(bool isLoggedIn) async { final l10n = AppLocalizations.of(context)!; if (!isLoggedIn) { context.push('/login?redirect=${Uri.encodeComponent('/finance/ido')}'); return; } final amount = _amountController.text.trim(); final err = await ref.read(stakingProvider.notifier).submitStake(amount); if (!mounted) { return; } if (err == null) { _amountController.clear(); showTopToast( context, message: l10n.financeStakeSuccess, backgroundColor: AppColors.rise, ); setState(() {}); return; } showTopToast(context, message: _resolveStakeError(err, l10n)); } String _resolveStakeError(String err, AppLocalizations l10n) { if (err == 'errNeedLogin') { return l10n.financeLoginToStake; } if (err == 'errEnterAmount') { return l10n.financeAmountRequired; } if (err == 'errExceedBalance') { return l10n.errExceedBalance; } if (err.startsWith('errAmountBelowMin:')) { return l10n.financeBelowMin(err.substring('errAmountBelowMin:'.length)); } if (err.startsWith('errAmountAboveMax:')) { return l10n.financeAboveMax(err.substring('errAmountAboveMax:'.length)); } return err; } } class _EmptyState extends StatelessWidget { const _EmptyState({required this.message}); final String message; @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; return Center( child: Padding( padding: const EdgeInsets.all(24), child: Column( mainAxisSize: MainAxisSize.min, children: [ Text( message, textAlign: TextAlign.center, style: TextStyle(color: _financeSecondaryText(context)), ), const SizedBox(height: 16), TextButton( onPressed: () => context.go('/'), child: Text(l10n.home), ), ], ), ), ); } } class _HeroSection extends StatelessWidget { const _HeroSection({ required this.l10n, required this.rulesText, this.loadingRules = false, }); final AppLocalizations l10n; final String rulesText; final bool loadingRules; @override Widget build(BuildContext context) { return Container( width: double.infinity, padding: const EdgeInsets.fromLTRB(16, 8, 16, 24), decoration: const BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ Color(0x0AF0B90B), Colors.transparent, ], stops: [0.0, 0.7], ), ), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( l10n.financeIdoTitle, style: TextStyle( color: _financePrimaryText(context), fontSize: 40, fontWeight: FontWeight.w700, height: 1.1, letterSpacing: 0.8, ), ), const SizedBox(height: 16), if (loadingRules) Container( height: 40, width: double.infinity, decoration: BoxDecoration( color: _financePrimaryText(context).withAlpha(14), borderRadius: BorderRadius.circular(6), ), ) else Text.rich( TextSpan( children: [ TextSpan( text: l10n.financeIdoRuleLabel, style: TextStyle(color: _financeLabelText(context)), ), TextSpan(text: rulesText), ], ), style: TextStyle( color: _financePrimaryText(context).withAlpha(191), fontSize: 15, height: 1.75, ), ), ], ), ), const SizedBox(width: 12), Image.asset( 'assets/images/finance/staking_hero.png', width: 120, height: 120, fit: BoxFit.contain, ), ], ), ); } } class _LabelRow extends StatelessWidget { const _LabelRow({ required this.label, this.actionLabel, this.onAction, }); final String label; final String? actionLabel; final VoidCallback? onAction; @override Widget build(BuildContext context) { return Row( children: [ Text( label, style: TextStyle( color: _financeLabelText(context), fontSize: 14, ), ), if (actionLabel != null && onAction != null) ...[ const Spacer(), _LinkAction(label: actionLabel!, onTap: onAction!), ], ], ); } } class _AvailableMetaRow extends StatelessWidget { const _AvailableMetaRow({ required this.l10n, required this.amount, required this.unit, required this.onFillAll, this.loading = false, }); final AppLocalizations l10n; final String amount; final String unit; final VoidCallback? onFillAll; final bool loading; @override Widget build(BuildContext context) { return Row( children: [ Expanded( child: Text.rich( TextSpan( text: '${l10n.financeAvailableIbit}: ', style: TextStyle( color: _financeSecondaryText(context), fontSize: 13), children: [ TextSpan( text: amount, style: const TextStyle( color: AppColors.brand, fontWeight: FontWeight.w600, ), ), TextSpan(text: ' $unit'), ], ), ), ), if (onFillAll != null) _LinkAction(label: l10n.all, onTap: onFillAll!) else if (loading) const SizedBox( width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 1.5), ), ], ); } } class _UnitPriceMetaRow extends StatelessWidget { const _UnitPriceMetaRow({ required this.l10n, required this.unitPrice, }); final AppLocalizations l10n; final Decimal unitPrice; @override Widget build(BuildContext context) { if (unitPrice <= Decimal.zero) { return const SizedBox(height: 20); } return Text( l10n.financeIbitUnitPriceLine( NumberFormat('#,##0.0000', 'en_US').format(unitPrice.toDouble()), ), style: TextStyle( color: _financeSecondaryText(context), fontSize: 13, ), ); } } class _LinkAction extends StatelessWidget { const _LinkAction({required this.label, required this.onTap}); final String label; final VoidCallback onTap; @override Widget build(BuildContext context) { return GestureDetector( onTap: onTap, child: Text( label, style: const TextStyle( color: AppColors.brand, fontSize: 13, ), ), ); } } class _FieldInput extends StatelessWidget { const _FieldInput({ this.controller, this.value, this.suffix, this.onChanged, this.readOnly = false, }); final TextEditingController? controller; final String? value; final String? suffix; final ValueChanged? onChanged; final bool readOnly; @override Widget build(BuildContext context) { final valueStyle = TextStyle( color: _financePrimaryText(context), fontSize: 16, height: 1.2, fontFeatures: const [FontFeature.tabularFigures()], ); final inputDecoration = InputDecoration( border: InputBorder.none, enabledBorder: InputBorder.none, focusedBorder: InputBorder.none, disabledBorder: InputBorder.none, errorBorder: InputBorder.none, focusedErrorBorder: InputBorder.none, filled: false, isDense: true, isCollapsed: true, contentPadding: EdgeInsets.zero, hintText: '0', hintStyle: TextStyle( color: _financePrimaryText(context).withAlpha(64), fontSize: 16, height: 1.2, fontFeatures: const [FontFeature.tabularFigures()], ), ); return Container( height: 52, padding: const EdgeInsets.symmetric(horizontal: 16), decoration: BoxDecoration( color: readOnly ? _financePrimaryText(context).withAlpha(8) : _financeFieldBg(context), border: Border.all(color: _financeFieldBorder(context)), borderRadius: BorderRadius.circular(12), ), child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ Expanded( child: readOnly ? Align( alignment: Alignment.centerLeft, child: Text( value ?? '0.00', maxLines: 1, overflow: TextOverflow.ellipsis, style: valueStyle, ), ) : Theme( data: Theme.of(context).copyWith( inputDecorationTheme: const InputDecorationTheme( filled: false, fillColor: Colors.transparent, border: InputBorder.none, enabledBorder: InputBorder.none, focusedBorder: InputBorder.none, disabledBorder: InputBorder.none, errorBorder: InputBorder.none, focusedErrorBorder: InputBorder.none, contentPadding: EdgeInsets.zero, isDense: true, ), ), child: TextField( controller: controller, readOnly: readOnly, keyboardType: const TextInputType.numberWithOptions( decimal: true, ), style: valueStyle, cursorColor: AppColors.brand, decoration: inputDecoration, onChanged: onChanged, ), ), ), if (suffix != null) ...[ const SizedBox(width: 12), Text( suffix!, style: TextStyle( color: _financeLabelText(context), fontSize: 14, ), ), ], ], ), ); } } class _TransferButton extends StatelessWidget { const _TransferButton({required this.label, required this.onPressed}); final String label; final VoidCallback onPressed; @override Widget build(BuildContext context) { return SizedBox( height: 52, child: OutlinedButton( onPressed: onPressed, style: OutlinedButton.styleFrom( side: const BorderSide(color: AppColors.brand), foregroundColor: AppColors.brand, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), padding: const EdgeInsets.symmetric(horizontal: 20), ), child: Text( label, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500), ), ), ); } }