| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706 |
- import 'package:flutter/material.dart';
- import 'package:flutter/foundation.dart';
- import 'package:flutter_riverpod/flutter_riverpod.dart';
- import 'package:go_router/go_router.dart';
- import '../../../core/l10n/app_localizations.dart';
- import '../../../core/theme/app_colors.dart';
- import '../../../core/utils/top_toast.dart';
- import '../../../data/models/finance/airdrop_eligibility.dart';
- import '../../../data/models/finance/airdrop_record_item.dart';
- import '../../../providers/auth_provider.dart';
- import '../../../providers/staking_provider.dart';
- bool _airdropIsDark(BuildContext context) =>
- Theme.of(context).brightness == Brightness.dark;
- Color _airdropPageBg(BuildContext context) => _airdropIsDark(context)
- ? const Color(0xFF010A14)
- : Theme.of(context).colorScheme.surface;
- Color _airdropCardBg(BuildContext context) => _airdropIsDark(context)
- ? const Color(0xFF11181D)
- : Theme.of(context).colorScheme.surfaceContainerHighest;
- Color _airdropPrimaryText(BuildContext context) => _airdropIsDark(context)
- ? Colors.white
- : Theme.of(context).colorScheme.onSurface;
- Color _airdropHintText(BuildContext context) =>
- _airdropPrimaryText(context).withAlpha(150);
- class AirdropScreen extends ConsumerStatefulWidget {
- const AirdropScreen({
- super.key,
- this.showAppBar = true,
- });
- final bool showAppBar;
- @override
- ConsumerState<AirdropScreen> createState() => _AirdropScreenState();
- }
- class _AirdropScreenState extends ConsumerState<AirdropScreen> {
- void _log(String message) {
- if (kDebugMode) {
- debugPrint('[Airdrop][UI] $message');
- }
- }
- @override
- void initState() {
- super.initState();
- // 避免在 build 阶段触发 provider 更新,导致布局重入断言。
- WidgetsBinding.instance.addPostFrameCallback((_) {
- if (mounted) {
- ref.read(airdropProvider.notifier).init();
- }
- });
- }
- @override
- Widget build(BuildContext context) {
- final l10n = AppLocalizations.of(context);
- final state = ref.watch(airdropProvider);
- final isLoggedIn = ref.watch(isLoggedInProvider);
- final eligibility = state.eligibility;
- final canClaim = isLoggedIn && eligibility.eligible && !state.isClaiming;
- final itemCount = _itemCount(state, isLoggedIn);
- _log(
- 'build isLoggedIn=$isLoggedIn eligLoading=${state.isLoadingEligibility} recordsLoading=${state.isLoadingRecords} records=${state.records.length} pageNo=${state.pageNo}/${state.totalPages} itemCount=$itemCount error=${state.errorMessage}',
- );
- final body = _buildBody(
- l10n: l10n,
- state: state,
- isLoggedIn: isLoggedIn,
- eligibility: eligibility,
- canClaim: canClaim,
- );
- if (!widget.showAppBar) {
- return ColoredBox(
- color: _airdropPageBg(context),
- child: body,
- );
- }
- return Scaffold(
- backgroundColor: _airdropPageBg(context),
- appBar: AppBar(
- backgroundColor: Colors.transparent,
- foregroundColor: _airdropPrimaryText(context),
- elevation: 0,
- centerTitle: true,
- title: Text(
- l10n?.airdropTitle ?? '',
- style: TextStyle(color: _airdropPrimaryText(context)),
- ),
- ),
- body: body,
- );
- }
- Future<void> _onRefresh() async {
- await ref.read(airdropProvider.notifier).init();
- }
- Widget _buildBody({
- required AppLocalizations? l10n,
- required AirdropState state,
- required bool isLoggedIn,
- required AirdropEligibility eligibility,
- required bool canClaim,
- }) {
- if (l10n == null) {
- return const Center(child: CircularProgressIndicator());
- }
- final itemCount = _itemCount(state, isLoggedIn);
- return RefreshIndicator(
- onRefresh: _onRefresh,
- color: AppColors.brand,
- child: ListView.builder(
- physics: const AlwaysScrollableScrollPhysics(),
- padding: const EdgeInsets.fromLTRB(15, 10, 15, 24),
- itemCount: itemCount,
- itemBuilder: (context, index) {
- return _itemBuilder(
- context: context,
- index: index,
- l10n: l10n,
- state: state,
- isLoggedIn: isLoggedIn,
- eligibility: eligibility,
- canClaim: canClaim,
- );
- },
- ),
- );
- }
- // ── 计算列表项总数 ──────────────────────────────────────────
- int _itemCount(AirdropState state, bool isLoggedIn) {
- int n = 0;
- n++; // hero
- if (state.errorMessage != null && state.errorMessage!.isNotEmpty) {
- n++; // error
- }
- n += 2; // section spacer + section title
- n += 2; // invite spacer + invite task
- n += 2; // staking spacer + staking task
- n += 2; // claimable spacer + claimable card
- n += 2; // claim button spacer + claim button
- if (!isLoggedIn) {
- n++; // login hint
- } else {
- n += 2; // records spacer + records title
- if (state.isLoadingRecords && state.records.isEmpty) {
- n++; // loading
- } else if (state.records.isEmpty) {
- n++; // empty
- } else {
- n += state.records.length; // record items
- }
- if (state.hasMore) n++; // load more
- }
- return n;
- }
- // ── 按 index 构建组件 ──────────────────────────────────────
- Widget _itemBuilder({
- required BuildContext context,
- required int index,
- required AppLocalizations l10n,
- required AirdropState state,
- required bool isLoggedIn,
- required AirdropEligibility eligibility,
- required bool canClaim,
- }) {
- int i = 0;
- final total = _itemCount(state, isLoggedIn);
- if (index < 0 || index >= total) {
- _log('itemBuilder out of range index=$index total=$total');
- _log(
- 'itemBuilder fallback index=$index total=$total records=${state.records.length} hasMore=${state.hasMore}',
- );
- return const SizedBox.shrink();
- }
- try {
- // 0: hero
- if (index == i++) {
- return _HeroPanel(
- title: l10n.airdropTitle,
- subtitle: eligibility.message.isNotEmpty
- ? eligibility.message
- : l10n.airdropHasPendingReward,
- loading: state.isLoadingEligibility,
- );
- }
- // 1: error (conditional)
- if (state.errorMessage != null && state.errorMessage!.isNotEmpty) {
- if (index == i++) {
- return _ErrorCard(message: state.errorMessage!);
- }
- }
- // section title
- if (index == i++) return const SizedBox(height: 20);
- if (index == i++) {
- return _SectionTitle(title: l10n.airdropTitle);
- }
- // invite task
- if (index == i++) return const SizedBox(height: 12);
- if (index == i++) {
- return _TaskCard(
- title: l10n.airdropInviteRequirement(
- '${eligibility.inviteCount}',
- '${eligibility.requiredInviteCount}',
- ),
- subtitle: eligibility.inviteTaskCompleted
- ? l10n.completed
- : '${eligibility.inviteCount}/${eligibility.requiredInviteCount}',
- done: eligibility.inviteTaskCompleted,
- btnText: l10n.inviteFriends,
- onTap: eligibility.inviteTaskCompleted
- ? null
- : () => context.push('/user/referral'),
- );
- }
- // staking task
- if (index == i++) return const SizedBox(height: 10);
- if (index == i++) {
- return _TaskCard(
- title: l10n.airdropHasActiveStaking,
- subtitle: eligibility.hasActiveStaking ? l10n.completed : l10n.noData,
- done: eligibility.hasActiveStaking,
- btnText: l10n.stakingTitle,
- onTap: () => context.push('/finance/ido'),
- );
- }
- // claimable card
- if (index == i++) return const SizedBox(height: 12);
- if (index == i++) {
- return _ClaimablePanel(
- label: l10n.airdropClaimable,
- amount: _fmtAmount(eligibility),
- coinUnit: eligibility.claimableCoinUnit ?? 'IBIT',
- );
- }
- // claim button
- if (index == i++) return const SizedBox(height: 16);
- if (index == i++) {
- return SizedBox(
- height: 50,
- child: ElevatedButton(
- onPressed: canClaim ? () => _onClaim(l10n, eligibility) : null,
- style: ElevatedButton.styleFrom(
- backgroundColor: AppColors.brand,
- foregroundColor: Colors.black,
- disabledBackgroundColor: _airdropCardBg(context),
- disabledForegroundColor: const Color(0xFF757575),
- shape: RoundedRectangleBorder(
- borderRadius: BorderRadius.circular(12)),
- ),
- child: state.isClaiming
- ? const SizedBox(
- width: 18,
- height: 18,
- child: CircularProgressIndicator(strokeWidth: 2))
- : Text(l10n.airdropClaimNow),
- ),
- );
- }
- // not logged in hint
- if (!isLoggedIn) {
- if (index == i++) {
- return Padding(
- padding: const EdgeInsets.only(top: 12),
- child: Center(
- child: Text(l10n.airdropNotEligible,
- style: TextStyle(
- color: _airdropHintText(context), fontSize: 12)),
- ),
- );
- }
- return const SizedBox.shrink();
- }
- // records section
- if (index == i++) return const SizedBox(height: 20);
- if (index == i++) {
- return Text(
- l10n.airdropRecords,
- style: TextStyle(
- color: _airdropPrimaryText(context),
- fontSize: 16,
- fontWeight: FontWeight.w600),
- );
- }
- if (state.isLoadingRecords && state.records.isEmpty) {
- if (index == i++) {
- return const Padding(
- padding: EdgeInsets.symmetric(vertical: 24),
- child: Center(
- child: SizedBox(
- width: 24,
- height: 24,
- child: CircularProgressIndicator(strokeWidth: 2))),
- );
- }
- } else if (state.records.isEmpty) {
- if (index == i++) {
- return Container(
- padding: const EdgeInsets.symmetric(vertical: 20),
- alignment: Alignment.center,
- decoration: BoxDecoration(
- color: _airdropCardBg(context),
- borderRadius: BorderRadius.circular(10)),
- child: Text(l10n.noData,
- style: TextStyle(color: _airdropHintText(context))),
- );
- }
- } else {
- final ri = index - i;
- if (ri >= 0 && ri < state.records.length) {
- return _RecordCard(record: state.records[ri]);
- }
- i += state.records.length;
- if (state.hasMore) {
- if (index == i++) {
- return TextButton(
- onPressed: state.isLoadingMore
- ? null
- : () => ref.read(airdropProvider.notifier).loadMore(),
- child: Text(state.isLoadingMore ? l10n.loading : l10n.viewMore,
- style: const TextStyle(color: AppColors.brand)),
- );
- }
- }
- }
- return const SizedBox.shrink();
- } catch (e, st) {
- _log(
- 'itemBuilder exception index=$index total=$total records=${state.records.length} error=$e',
- );
- _log('$st');
- return Container(
- margin: const EdgeInsets.only(top: 8),
- padding: const EdgeInsets.all(12),
- decoration: BoxDecoration(
- color: const Color(0x33FF5252),
- borderRadius: BorderRadius.circular(8),
- border: Border.all(color: const Color(0x66FF5252)),
- ),
- child: Text(
- 'UI render error @index=$index: $e',
- style: const TextStyle(color: Color(0xFFFF8A80), fontSize: 12),
- ),
- );
- }
- }
- // ── 格式化金额 ──────────────────────────────────────────────
- String _fmtAmount(AirdropEligibility e) {
- final d = double.tryParse(e.claimableAmount) ?? 0;
- if (d == 0) return '0';
- final s = d.toStringAsFixed(8);
- // 去掉尾部多余的 0
- final trimmed = s.replaceAll(RegExp(r'0+$'), '');
- return trimmed.endsWith('.') ? '${trimmed}0' : trimmed;
- }
- // ── 领取逻辑 ────────────────────────────────────────────────
- Future<void> _onClaim(
- AppLocalizations l10n, AirdropEligibility eligibility) async {
- if (!eligibility.eligible) {
- showTopToast(context, message: l10n.airdropNotEligible);
- return;
- }
- final err = await ref.read(airdropProvider.notifier).claim();
- if (!mounted) return;
- if (err == null) {
- await showDialog(
- context: context,
- builder: (ctx) => AlertDialog(
- title: Text(l10n.tips),
- content: Text(l10n.airdropClaimSuccess),
- actions: [
- TextButton(
- onPressed: () => Navigator.of(ctx).pop(),
- child: Text(l10n.confirm)),
- ],
- ),
- );
- } else {
- showTopToast(context, message: err);
- }
- }
- }
- // ═══════════════════════════════════════════════════════════════
- // 子组件
- // ═══════════════════════════════════════════════════════════════
- // ── 英雄区(文字左 + 图片右)──────────────────────────────────
- class _HeroPanel extends StatelessWidget {
- const _HeroPanel(
- {required this.title, required this.subtitle, this.loading = false});
- final String title;
- final String subtitle;
- final bool loading;
- @override
- Widget build(BuildContext context) {
- return Padding(
- padding: const EdgeInsets.only(bottom: 8),
- child: Row(
- crossAxisAlignment: CrossAxisAlignment.center,
- children: [
- Expanded(
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- mainAxisSize: MainAxisSize.min,
- children: [
- Text(title,
- style: TextStyle(
- color: _airdropPrimaryText(context),
- fontSize: 26,
- fontWeight: FontWeight.w700)),
- const SizedBox(height: 8),
- if (loading)
- const SizedBox(
- width: 18,
- height: 18,
- child: CircularProgressIndicator(strokeWidth: 2))
- else
- Text(subtitle,
- maxLines: 5,
- overflow: TextOverflow.ellipsis,
- style: TextStyle(
- color: _airdropHintText(context),
- fontSize: 10,
- height: 1.45)),
- ],
- ),
- ),
- const SizedBox(width: 16),
- Image.asset(
- 'assets/images/finance/airdrop_hero.png',
- width: 126,
- height: 126,
- fit: BoxFit.contain,
- errorBuilder: (_, __, ___) => Container(
- width: 126,
- height: 126,
- alignment: Alignment.center,
- decoration: BoxDecoration(
- color: _airdropCardBg(context),
- borderRadius: BorderRadius.circular(12),
- ),
- child: const Icon(Icons.card_giftcard_outlined,
- color: AppColors.brand, size: 48),
- ),
- ),
- ],
- ),
- );
- }
- }
- // ── 错误卡片 ──────────────────────────────────────────────────
- class _ErrorCard extends StatelessWidget {
- const _ErrorCard({required this.message});
- final String message;
- @override
- Widget build(BuildContext context) {
- return Container(
- margin: const EdgeInsets.only(bottom: 8),
- padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
- decoration: BoxDecoration(
- color: const Color(0x33FF5252),
- borderRadius: BorderRadius.circular(8),
- border: Border.all(color: const Color(0x66FF5252)),
- ),
- child: Text(message,
- style: const TextStyle(color: Color(0xFFFF8A80), fontSize: 12)),
- );
- }
- }
- // ── 区块标题 ──────────────────────────────────────────────────
- class _SectionTitle extends StatelessWidget {
- const _SectionTitle({required this.title});
- final String title;
- @override
- Widget build(BuildContext context) {
- return Center(
- child: Row(
- mainAxisSize: MainAxisSize.min,
- children: [
- Container(
- width: 14,
- height: 3,
- decoration: BoxDecoration(
- color: AppColors.brand,
- borderRadius: BorderRadius.circular(2))),
- const SizedBox(width: 10),
- Text(title,
- style: TextStyle(
- color: _airdropPrimaryText(context),
- fontSize: 14,
- fontWeight: FontWeight.w600)),
- const SizedBox(width: 10),
- Container(
- width: 14,
- height: 3,
- decoration: BoxDecoration(
- color: AppColors.brand,
- borderRadius: BorderRadius.circular(2))),
- ],
- ),
- );
- }
- }
- // ── 任务卡片 ──────────────────────────────────────────────────
- class _TaskCard extends StatelessWidget {
- const _TaskCard({
- required this.title,
- required this.subtitle,
- required this.done,
- required this.btnText,
- this.onTap,
- });
- final String title;
- final String subtitle;
- final bool done;
- final String btnText;
- final VoidCallback? onTap;
- @override
- Widget build(BuildContext context) {
- return Container(
- padding: const EdgeInsets.symmetric(horizontal: 15, vertical: 14),
- decoration: BoxDecoration(
- color: _airdropCardBg(context),
- borderRadius: BorderRadius.circular(8)),
- child: Row(
- children: [
- Expanded(
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- mainAxisSize: MainAxisSize.min,
- children: [
- Text(title,
- style: TextStyle(
- color: _airdropPrimaryText(context), fontSize: 13)),
- const SizedBox(height: 6),
- Text(
- done ? '$subtitle ✅' : subtitle,
- style:
- TextStyle(color: _airdropHintText(context), fontSize: 11),
- ),
- ],
- ),
- ),
- const SizedBox(width: 12),
- SizedBox(
- height: 30,
- child: Material(
- color: onTap == null
- ? AppColors.brand.withAlpha(128)
- : AppColors.brand,
- borderRadius: BorderRadius.circular(6),
- child: InkWell(
- onTap: onTap,
- borderRadius: BorderRadius.circular(6),
- child: Padding(
- padding: const EdgeInsets.symmetric(horizontal: 12),
- child: Center(
- child: Text(
- btnText,
- style: TextStyle(
- fontSize: 12,
- color: onTap == null
- ? Colors.black.withAlpha(128)
- : Colors.black,
- fontWeight: FontWeight.w500,
- ),
- ),
- ),
- ),
- ),
- ),
- ),
- ],
- ),
- );
- }
- }
- // ── 可领取金额面板 ────────────────────────────────────────────
- class _ClaimablePanel extends StatelessWidget {
- const _ClaimablePanel(
- {required this.label, required this.amount, required this.coinUnit});
- final String label;
- final String amount;
- final String coinUnit;
- @override
- Widget build(BuildContext context) {
- return Container(
- padding: const EdgeInsets.symmetric(horizontal: 15, vertical: 14),
- decoration: BoxDecoration(
- color: _airdropCardBg(context),
- borderRadius: BorderRadius.circular(8)),
- child: Row(
- children: [
- Expanded(
- child: Text(label,
- style:
- TextStyle(color: _airdropHintText(context), fontSize: 12)),
- ),
- Text('$amount $coinUnit',
- style: TextStyle(
- color: _airdropPrimaryText(context),
- fontSize: 18,
- fontWeight: FontWeight.w700)),
- ],
- ),
- );
- }
- }
- // ── 领取记录条目 ──────────────────────────────────────────────
- class _RecordCard extends StatelessWidget {
- const _RecordCard({required this.record});
- final AirdropRecordItem record;
- @override
- Widget build(BuildContext context) {
- final l10n = AppLocalizations.of(context);
- final statusText = switch (record.status) {
- 0 => l10n?.airdropStatusPending ?? 'Pending',
- 1 => l10n?.airdropStatusGranted ?? 'Granted',
- 2 => l10n?.airdropStatusReviewing ?? 'Reviewing',
- 3 => l10n?.airdropStatusRejected ?? 'Rejected',
- _ => '${record.status}',
- };
- return Container(
- margin: const EdgeInsets.only(bottom: 8),
- padding: const EdgeInsets.all(12),
- decoration: BoxDecoration(
- color: _airdropCardBg(context),
- borderRadius: BorderRadius.circular(8)),
- child: Row(
- children: [
- Expanded(
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- mainAxisSize: MainAxisSize.min,
- children: [
- Text(
- '${record.amount} ${record.coinUnit ?? ''}',
- style: TextStyle(
- fontWeight: FontWeight.w600,
- color: _airdropPrimaryText(context)),
- ),
- const SizedBox(height: 4),
- Text(
- record.createTime ?? '--',
- style:
- TextStyle(color: _airdropHintText(context), fontSize: 12),
- ),
- ],
- ),
- ),
- Text(statusText, style: TextStyle(color: _airdropHintText(context))),
- ],
- ),
- );
- }
- }
|