| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820 |
- 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<StakingScreen> createState() => _StakingScreenState();
- }
- class _StakingScreenState extends ConsumerState<StakingScreen> {
- 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<void> _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<void> _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<String>? 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),
- ),
- ),
- );
- }
- }
|