feat(mining-app): shimmer placeholder for all pages

Replace skeleton blocks with shimmer text placeholders across all pages:
- Asset page: show full UI with flickering numbers during load
- Trading page: show full UI with flickering market data during load
- Contribution page: show full UI with flickering stats during load
- shimmer_loading.dart: add ShimmerText, DataText, AmountText components

This approach shows the complete UI immediately, with only the dynamic
number values flickering while data loads - much better UX than showing
grey skeleton blocks.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-01-12 21:56:22 -08:00
parent b1525bdfa6
commit eba4b3b6e5
4 changed files with 561 additions and 536 deletions

View File

@ -1,6 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/constants/app_colors.dart';
import '../../../core/utils/format_utils.dart';
import '../../providers/user_providers.dart';
import '../../providers/mining_providers.dart';
@ -27,6 +26,10 @@ class AssetPage extends ConsumerWidget {
final accountSequence = user.accountSequence ?? '';
final accountAsync = ref.watch(shareAccountProvider(accountSequence));
//
final isLoading = accountAsync.isLoading;
final account = accountAsync.valueOrNull;
return Scaffold(
backgroundColor: Colors.white,
body: SafeArea(
@ -51,32 +54,20 @@ class AssetPage extends ConsumerWidget {
child: Column(
children: [
const SizedBox(height: 8),
//
accountAsync.when(
data: (account) => _buildTotalAssetCard(account),
loading: () => _buildLoadingCard(),
error: (_, __) => _buildErrorCard('资产加载失败'),
),
// -
_buildTotalAssetCard(account, isLoading),
const SizedBox(height: 24),
//
_buildQuickActions(),
const SizedBox(height: 24),
//
accountAsync.when(
data: (account) => _buildAssetList(account),
loading: () => _buildAssetListSkeleton(),
error: (_, __) => const SizedBox.shrink(),
),
// -
_buildAssetList(account, isLoading),
const SizedBox(height: 24),
//
_buildEarningsCard(),
_buildEarningsCard(account, isLoading),
const SizedBox(height: 24),
//
accountAsync.when(
data: (account) => _buildAccountList(account),
loading: () => _buildAssetListSkeleton(),
error: (_, __) => const SizedBox.shrink(),
),
_buildAccountList(account, isLoading),
const SizedBox(height: 100),
],
),
@ -190,8 +181,7 @@ class AssetPage extends ConsumerWidget {
);
}
Widget _buildTotalAssetCard(account) {
final totalAsset = account?.tradingBalance ?? '88888.88';
Widget _buildTotalAssetCard(account, bool isLoading) {
return Container(
decoration: BoxDecoration(
color: Colors.white,
@ -226,11 +216,11 @@ class AssetPage extends ConsumerWidget {
right: 0,
child: Container(
height: 4,
decoration: BoxDecoration(
gradient: const LinearGradient(
decoration: const BoxDecoration(
gradient: LinearGradient(
colors: [Color(0xFFFF6B00), Color(0xFFFDBA74)],
),
borderRadius: const BorderRadius.only(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(20),
topRight: Radius.circular(20),
),
@ -262,9 +252,11 @@ class AssetPage extends ConsumerWidget {
],
),
const SizedBox(height: 8),
//
Text(
'¥ ${formatAmount(totalAsset)}',
// -
AmountText(
amount: account != null ? formatAmount(account.tradingBalance ?? '0') : null,
isLoading: isLoading,
prefix: '¥ ',
style: const TextStyle(
fontSize: 30,
fontWeight: FontWeight.bold,
@ -274,9 +266,11 @@ class AssetPage extends ConsumerWidget {
),
const SizedBox(height: 4),
// USDT估值
const Text(
'≈ 12,345.67 USDT',
style: TextStyle(
DataText(
data: account != null ? '≈ 12,345.67 USDT' : null,
isLoading: isLoading,
placeholder: '≈ -- USDT',
style: const TextStyle(
fontSize: 14,
color: Color(0xFF9CA3AF),
),
@ -292,11 +286,13 @@ class AssetPage extends ConsumerWidget {
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.trending_up, size: 14, color: _green),
const Icon(Icons.trending_up, size: 14, color: _green),
const SizedBox(width: 6),
Text(
'+¥ 156.78 今日',
style: TextStyle(
DataText(
data: account != null ? '+¥ 156.78 今日' : null,
isLoading: isLoading,
placeholder: '+¥ -- 今日',
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: _green,
@ -350,7 +346,7 @@ class AssetPage extends ConsumerWidget {
);
}
Widget _buildAssetList(account) {
Widget _buildAssetList(account, bool isLoading) {
return Column(
children: [
//
@ -359,7 +355,8 @@ class AssetPage extends ConsumerWidget {
iconColor: _orange,
iconBgColor: _serenade,
title: '积分股',
amount: account?.miningBalance ?? '123456.78',
amount: account?.miningBalance,
isLoading: isLoading,
valueInCny: '¥15,234.56',
tag: '含倍数资产: 246,913.56',
growthText: '每秒 +0.0015',
@ -371,7 +368,8 @@ class AssetPage extends ConsumerWidget {
iconColor: _green,
iconBgColor: _feta,
title: '绿积分',
amount: account?.tradingBalance ?? '88888.88',
amount: account?.tradingBalance,
isLoading: isLoading,
valueInCny: '¥10,986.54',
badge: '可提现',
badgeColor: _jewel,
@ -385,6 +383,7 @@ class AssetPage extends ConsumerWidget {
iconBgColor: _serenade,
title: '待分配积分股',
amount: '1,234.56',
isLoading: isLoading,
subtitle: '次日开始参与分配',
),
],
@ -396,7 +395,8 @@ class AssetPage extends ConsumerWidget {
required Color iconColor,
required Color iconBgColor,
required String title,
required String amount,
String? amount,
bool isLoading = false,
String? valueInCny,
String? tag,
String? growthText,
@ -468,9 +468,11 @@ class AssetPage extends ConsumerWidget {
],
),
const SizedBox(height: 2),
//
Text(
formatAmount(amount),
// -
DataText(
data: amount != null ? formatAmount(amount) : null,
isLoading: isLoading,
placeholder: '--',
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
@ -479,8 +481,10 @@ class AssetPage extends ConsumerWidget {
),
//
if (valueInCny != null)
Text(
'$valueInCny',
DataText(
data: isLoading ? null : '$valueInCny',
isLoading: isLoading,
placeholder: '≈ ¥--',
style: const TextStyle(
fontSize: 12,
color: Color(0xFF9CA3AF),
@ -511,8 +515,10 @@ class AssetPage extends ConsumerWidget {
color: _serenade,
borderRadius: BorderRadius.circular(12),
),
child: Text(
tag,
child: DataText(
data: isLoading ? null : tag,
isLoading: isLoading,
placeholder: '含倍数资产: --',
style: const TextStyle(
fontSize: 10,
color: _orange,
@ -523,10 +529,12 @@ class AssetPage extends ConsumerWidget {
const SizedBox(width: 8),
Row(
children: [
Icon(Icons.bolt, size: 12, color: _green),
Text(
growthText,
style: TextStyle(
const Icon(Icons.bolt, size: 12, color: _green),
DataText(
data: isLoading ? null : growthText,
isLoading: isLoading,
placeholder: '每秒 +--',
style: const TextStyle(
fontSize: 10,
color: _green,
),
@ -547,7 +555,7 @@ class AssetPage extends ConsumerWidget {
);
}
Widget _buildEarningsCard() {
Widget _buildEarningsCard(account, bool isLoading) {
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
@ -589,19 +597,19 @@ class AssetPage extends ConsumerWidget {
//
Row(
children: [
_buildEarningsItem('累计收益', '12,345.67', _orange),
_buildEarningsItem('累计收益', isLoading ? null : '12,345.67', _orange, isLoading),
Container(
width: 1,
height: 40,
color: _serenade,
),
_buildEarningsItem('今日收益', '+156.78', _green),
_buildEarningsItem('今日收益', isLoading ? null : '+156.78', _green, isLoading),
Container(
width: 1,
height: 40,
color: _serenade,
),
_buildEarningsItem('昨日收益', '143.21', const Color(0xFF9CA3AF)),
_buildEarningsItem('昨日收益', isLoading ? null : '143.21', const Color(0xFF9CA3AF), isLoading),
],
),
],
@ -609,7 +617,7 @@ class AssetPage extends ConsumerWidget {
);
}
Widget _buildEarningsItem(String label, String value, Color valueColor) {
Widget _buildEarningsItem(String label, String? value, Color valueColor, bool isLoading) {
return Expanded(
child: Column(
children: [
@ -622,8 +630,10 @@ class AssetPage extends ConsumerWidget {
textAlign: TextAlign.center,
),
const SizedBox(height: 4),
Text(
value,
DataText(
data: value,
isLoading: isLoading,
placeholder: '--',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
@ -636,7 +646,7 @@ class AssetPage extends ConsumerWidget {
);
}
Widget _buildAccountList(account) {
Widget _buildAccountList(account, bool isLoading) {
return Column(
children: [
//
@ -644,7 +654,8 @@ class AssetPage extends ConsumerWidget {
icon: Icons.account_balance_wallet,
iconColor: _orange,
title: '交易账户',
balance: account?.tradingBalance ?? '5678.90',
balance: account?.tradingBalance,
isLoading: isLoading,
unit: '绿积分',
status: '正常',
statusColor: _green,
@ -657,6 +668,7 @@ class AssetPage extends ConsumerWidget {
iconColor: _orange,
title: '提现账户',
balance: '1,234.56',
isLoading: isLoading,
unit: '绿积分',
status: '已绑定',
statusColor: const Color(0xFF9CA3AF),
@ -671,7 +683,8 @@ class AssetPage extends ConsumerWidget {
required IconData icon,
required Color iconColor,
required String title,
required String balance,
String? balance,
bool isLoading = false,
required String unit,
required String status,
required Color statusColor,
@ -718,26 +731,26 @@ class AssetPage extends ConsumerWidget {
),
),
const SizedBox(height: 2),
RichText(
text: TextSpan(
children: [
TextSpan(
text: '$balance ',
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: _orange,
),
Row(
children: [
DataText(
data: balance,
isLoading: isLoading,
placeholder: '--',
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: _orange,
),
TextSpan(
text: unit,
style: const TextStyle(
fontSize: 12,
color: Color(0xFF9CA3AF),
),
),
Text(
' $unit',
style: const TextStyle(
fontSize: 12,
color: Color(0xFF9CA3AF),
),
],
),
),
],
),
],
),
@ -766,38 +779,4 @@ class AssetPage extends ConsumerWidget {
);
}
Widget _buildLoadingCard() {
return const AssetCardSkeleton();
}
Widget _buildAssetListSkeleton() {
return Column(
children: const [
AssetItemSkeleton(),
SizedBox(height: 16),
AssetItemSkeleton(),
SizedBox(height: 16),
AssetItemSkeleton(),
],
);
}
Widget _buildErrorCard(String message) {
return Container(
padding: const EdgeInsets.all(32),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
),
child: Center(
child: Column(
children: [
const Icon(Icons.error_outline, size: 48, color: AppColors.error),
const SizedBox(height: 8),
Text(message),
],
),
),
);
}
}

View File

@ -32,6 +32,12 @@ class ContributionPage extends ConsumerWidget {
);
final recordsAsync = ref.watch(contributionRecordsProvider(recordsParams));
// Extract loading state and data from AsyncValue
final isLoading = contributionAsync.isLoading;
final contribution = contributionAsync.valueOrNull;
final hasError = contributionAsync.hasError;
final error = contributionAsync.error;
return Scaffold(
backgroundColor: const Color(0xFFF5F5F5),
body: SafeArea(
@ -41,58 +47,54 @@ class ContributionPage extends ConsumerWidget {
ref.invalidate(contributionProvider(accountSequence));
ref.invalidate(contributionRecordsProvider(recordsParams));
},
child: contributionAsync.when(
data: (contribution) {
return CustomScrollView(
slivers: [
//
SliverToBoxAdapter(child: _buildAppBar(context)),
//
SliverPadding(
padding: const EdgeInsets.all(16),
sliver: SliverList(
delegate: SliverChildListDelegate([
//
_buildTotalContributionCard(contribution),
const SizedBox(height: 16),
//
_buildThreeColumnStats(contribution),
const SizedBox(height: 16),
//
_buildTodayEstimateCard(contribution),
const SizedBox(height: 16),
//
_buildContributionDetailCard(context, ref, recordsAsync),
const SizedBox(height: 16),
//
_buildTeamStatsCard(contribution),
const SizedBox(height: 16),
//
_buildExpirationCard(contribution, recordsAsync),
const SizedBox(height: 100),
]),
child: hasError && contribution == null
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline, size: 48, color: AppColors.error),
const SizedBox(height: 16),
Text('加载失败: $error'),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () => ref.invalidate(contributionProvider(accountSequence)),
child: const Text('重试'),
),
],
),
)
: CustomScrollView(
slivers: [
//
SliverToBoxAdapter(child: _buildAppBar(context)),
//
SliverPadding(
padding: const EdgeInsets.all(16),
sliver: SliverList(
delegate: SliverChildListDelegate([
//
_buildTotalContributionCard(contribution, isLoading),
const SizedBox(height: 16),
//
_buildThreeColumnStats(contribution, isLoading),
const SizedBox(height: 16),
//
_buildTodayEstimateCard(contribution, isLoading),
const SizedBox(height: 16),
//
_buildContributionDetailCard(context, ref, recordsAsync),
const SizedBox(height: 16),
//
_buildTeamStatsCard(contribution, isLoading),
const SizedBox(height: 16),
//
_buildExpirationCard(contribution, recordsAsync, isLoading),
const SizedBox(height: 100),
]),
),
),
),
],
);
},
loading: () => const PageSkeleton(),
error: (error, _) => Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline, size: 48, color: AppColors.error),
const SizedBox(height: 16),
Text('加载失败: $error'),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () => ref.invalidate(contributionProvider(accountSequence)),
child: const Text('重试'),
),
],
),
),
),
],
),
),
),
);
@ -156,7 +158,7 @@ class ContributionPage extends ConsumerWidget {
);
}
Widget _buildTotalContributionCard(Contribution? contribution) {
Widget _buildTotalContributionCard(Contribution? contribution, bool isLoading) {
final total = contribution?.effectiveContribution ?? '0';
return Container(
padding: const EdgeInsets.all(20),
@ -178,8 +180,10 @@ class ContributionPage extends ConsumerWidget {
],
),
const SizedBox(height: 8),
Text(
formatAmount(total),
DataText(
data: isLoading ? null : formatAmount(total),
isLoading: isLoading,
placeholder: '----',
style: const TextStyle(
fontSize: 30,
fontWeight: FontWeight.bold,
@ -212,7 +216,7 @@ class ContributionPage extends ConsumerWidget {
);
}
Widget _buildThreeColumnStats(Contribution? contribution) {
Widget _buildThreeColumnStats(Contribution? contribution, bool isLoading) {
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
@ -221,15 +225,15 @@ class ContributionPage extends ConsumerWidget {
),
child: Row(
children: [
_buildStatColumn('个人贡献值', contribution?.personalContribution ?? '0', false),
_buildStatColumn('团队贡献值', contribution?.teamLevelContribution ?? '0', true),
_buildStatColumn('省市公司', contribution?.systemContribution ?? '0', true),
_buildStatColumn('个人贡献值', contribution?.personalContribution, isLoading, false),
_buildStatColumn('团队贡献值', contribution?.teamLevelContribution, isLoading, true),
_buildStatColumn('省市公司', contribution?.systemContribution, isLoading, true),
],
),
);
}
Widget _buildStatColumn(String label, String value, bool showLeftBorder) {
Widget _buildStatColumn(String label, String? value, bool isLoading, bool showLeftBorder) {
return Expanded(
child: Container(
decoration: showLeftBorder
@ -242,9 +246,12 @@ class ContributionPage extends ConsumerWidget {
children: [
Text(label, style: const TextStyle(fontSize: 12, color: _grayText)),
const SizedBox(height: 4),
Text(
formatAmount(value),
DataText(
data: value != null ? formatAmount(value) : null,
isLoading: isLoading,
placeholder: '--',
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold, color: _darkText),
textAlign: TextAlign.center,
),
],
),
@ -252,7 +259,7 @@ class ContributionPage extends ConsumerWidget {
);
}
Widget _buildTodayEstimateCard(Contribution? contribution) {
Widget _buildTodayEstimateCard(Contribution? contribution, bool isLoading) {
// API
final effectiveContribution = double.tryParse(contribution?.effectiveContribution ?? '0') ?? 0;
// 10000
@ -298,24 +305,29 @@ class ContributionPage extends ConsumerWidget {
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text.rich(
TextSpan(
children: [
TextSpan(
text: hasContribution ? '计算中' : '--',
style: TextStyle(
fontSize: hasContribution ? 14 : 18,
fontWeight: FontWeight.bold,
color: _green,
isLoading
? const ShimmerText(
placeholder: '-- 积分股',
style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold, color: _green),
)
: Text.rich(
TextSpan(
children: [
TextSpan(
text: hasContribution ? '计算中' : '--',
style: TextStyle(
fontSize: hasContribution ? 14 : 18,
fontWeight: FontWeight.bold,
color: _green,
),
),
const TextSpan(
text: ' 积分股',
style: TextStyle(fontSize: 12, color: _green),
),
],
),
),
const TextSpan(
text: ' 积分股',
style: TextStyle(fontSize: 12, color: _green),
),
],
),
),
],
),
],
@ -328,6 +340,9 @@ class ContributionPage extends ConsumerWidget {
WidgetRef ref,
AsyncValue<ContributionRecordsPage?> recordsAsync,
) {
final isRecordsLoading = recordsAsync.isLoading;
final recordsPage = recordsAsync.valueOrNull;
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
@ -359,55 +374,83 @@ class ContributionPage extends ConsumerWidget {
),
const SizedBox(height: 16),
//
recordsAsync.when(
data: (recordsPage) {
if (recordsPage == null || recordsPage.data.isEmpty) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 20),
child: Text(
'暂无贡献值记录',
style: TextStyle(fontSize: 14, color: _grayText),
),
);
}
return Column(
children: recordsPage.data.asMap().entries.map((entry) {
final index = entry.key;
final record = entry.value;
return Column(
children: [
_buildDetailRow(record),
if (index < recordsPage.data.length - 1) const Divider(height: 24),
],
);
}).toList(),
);
},
loading: () => Padding(
padding: const EdgeInsets.symmetric(vertical: 20),
child: ShimmerLoading(
child: Column(
children: const [
ShimmerBox(height: 48),
SizedBox(height: 12),
ShimmerBox(height: 48),
],
),
),
),
error: (error, _) => Padding(
if (isRecordsLoading)
_buildRecordsShimmer()
else if (recordsAsync.hasError && recordsPage == null)
Padding(
padding: const EdgeInsets.symmetric(vertical: 20),
child: Text(
'加载失败',
style: TextStyle(fontSize: 14, color: _grayText.withOpacity(0.7)),
),
)
else if (recordsPage == null || recordsPage.data.isEmpty)
const Padding(
padding: EdgeInsets.symmetric(vertical: 20),
child: Text(
'暂无贡献值记录',
style: TextStyle(fontSize: 14, color: _grayText),
),
)
else
Column(
children: recordsPage.data.asMap().entries.map((entry) {
final index = entry.key;
final record = entry.value;
return Column(
children: [
_buildDetailRow(record),
if (index < recordsPage.data.length - 1) const Divider(height: 24),
],
);
}).toList(),
),
),
],
),
);
}
Widget _buildRecordsShimmer() {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Column(
children: [
_buildShimmerDetailRow(),
const Divider(height: 24),
_buildShimmerDetailRow(),
const Divider(height: 24),
_buildShimmerDetailRow(),
],
),
);
}
Widget _buildShimmerDetailRow() {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: const [
ShimmerText(
placeholder: '认种贡献',
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500, color: _darkText),
),
SizedBox(height: 2),
ShimmerText(
placeholder: '2024-01-01 12:00',
style: TextStyle(fontSize: 12, color: _grayText),
),
],
),
const ShimmerText(
placeholder: '+1,000',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: _green),
),
],
);
}
Widget _buildDetailRow(ContributionRecord record) {
final dateFormat = DateFormat('yyyy-MM-dd HH:mm');
final formattedDate = dateFormat.format(record.createdAt);
@ -435,7 +478,7 @@ class ContributionPage extends ConsumerWidget {
);
}
Widget _buildTeamStatsCard(Contribution? contribution) {
Widget _buildTeamStatsCard(Contribution? contribution, bool isLoading) {
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
@ -453,18 +496,38 @@ class ContributionPage extends ConsumerWidget {
//
Row(
children: [
_buildTeamStatItem('直推人数', '${contribution?.directReferralAdoptedCount ?? 0}', ''),
_buildTeamStatItem(
'直推人数',
contribution?.directReferralAdoptedCount.toString(),
'',
isLoading,
),
const SizedBox(width: 16),
_buildTeamStatItem('已解锁奖励', '${contribution?.unlockedBonusTiers ?? 0}', ''),
_buildTeamStatItem(
'已解锁奖励',
contribution?.unlockedBonusTiers.toString(),
'',
isLoading,
),
],
),
const SizedBox(height: 16),
//
Row(
children: [
_buildTeamStatItem('已解锁层级', '${contribution?.unlockedLevelDepth ?? 0}', ''),
_buildTeamStatItem(
'已解锁层级',
contribution?.unlockedLevelDepth.toString(),
'',
isLoading,
),
const SizedBox(width: 16),
_buildTeamStatItem('是否认种', contribution?.hasAdopted == true ? '' : '', ''),
_buildTeamStatItem(
'是否认种',
contribution != null ? (contribution.hasAdopted == true ? '' : '') : null,
'',
isLoading,
),
],
),
],
@ -472,7 +535,7 @@ class ContributionPage extends ConsumerWidget {
);
}
Widget _buildTeamStatItem(String label, String value, String unit) {
Widget _buildTeamStatItem(String label, String? value, String unit, bool isLoading) {
return Expanded(
child: Container(
padding: const EdgeInsets.all(12),
@ -485,21 +548,26 @@ class ContributionPage extends ConsumerWidget {
children: [
Text(label, style: const TextStyle(fontSize: 12, color: _grayText)),
const SizedBox(height: 4),
Text.rich(
TextSpan(
children: [
TextSpan(
text: '$value ',
isLoading
? ShimmerText(
placeholder: unit.isNotEmpty ? '-- $unit' : '--',
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: _orange),
),
if (unit.isNotEmpty)
)
: Text.rich(
TextSpan(
text: unit,
style: const TextStyle(fontSize: 12, color: _grayText),
children: [
TextSpan(
text: '${value ?? '0'} ',
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: _orange),
),
if (unit.isNotEmpty)
TextSpan(
text: unit,
style: const TextStyle(fontSize: 12, color: _grayText),
),
],
),
],
),
),
),
],
),
),
@ -509,19 +577,21 @@ class ContributionPage extends ConsumerWidget {
Widget _buildExpirationCard(
Contribution? contribution,
AsyncValue<ContributionRecordsPage?> recordsAsync,
bool isLoading,
) {
final isRecordsLoading = recordsAsync.isLoading;
final recordsPage = recordsAsync.valueOrNull;
//
DateTime? nearestExpireDate;
recordsAsync.whenData((recordsPage) {
if (recordsPage != null && recordsPage.data.isNotEmpty) {
//
final activeRecords = recordsPage.data.where((r) => !r.isExpired).toList();
if (activeRecords.isNotEmpty) {
activeRecords.sort((a, b) => a.expireDate.compareTo(b.expireDate));
nearestExpireDate = activeRecords.first.expireDate;
}
if (recordsPage != null && recordsPage.data.isNotEmpty) {
//
final activeRecords = recordsPage.data.where((r) => !r.isExpired).toList();
if (activeRecords.isNotEmpty) {
activeRecords.sort((a, b) => a.expireDate.compareTo(b.expireDate));
nearestExpireDate = activeRecords.first.expireDate;
}
});
}
//
final now = DateTime.now();
@ -530,15 +600,17 @@ class ContributionPage extends ConsumerWidget {
String expireDateText = '暂无过期信息';
if (nearestExpireDate != null) {
daysRemaining = nearestExpireDate!.difference(now).inDays;
daysRemaining = nearestExpireDate.difference(now).inDays;
if (daysRemaining < 0) daysRemaining = 0;
// 730
progress = daysRemaining / 730;
if (progress > 1) progress = 1;
if (progress < 0) progress = 0;
expireDateText = '您的贡献值将于 ${DateFormat('yyyy-MM-dd').format(nearestExpireDate!)} 失效';
expireDateText = '您的贡献值将于 ${DateFormat('yyyy-MM-dd').format(nearestExpireDate)} 失效';
}
final showShimmer = isLoading || isRecordsLoading;
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
@ -564,23 +636,35 @@ class ContributionPage extends ConsumerWidget {
ClipRRect(
borderRadius: BorderRadius.circular(5),
child: LinearProgressIndicator(
value: progress,
value: showShimmer ? 1.0 : progress,
minHeight: 10,
backgroundColor: _bgGray,
valueColor: const AlwaysStoppedAnimation<Color>(_orange),
valueColor: AlwaysStoppedAnimation<Color>(
showShimmer ? _bgGray : _orange,
),
),
),
const SizedBox(height: 12),
//
Text(
expireDateText,
style: const TextStyle(fontSize: 12, color: _grayText),
),
showShimmer
? const ShimmerText(
placeholder: '您的贡献值将于 ---- 失效',
style: TextStyle(fontSize: 12, color: _grayText),
)
: Text(
expireDateText,
style: const TextStyle(fontSize: 12, color: _grayText),
),
const SizedBox(height: 4),
Text(
'剩余 $daysRemaining',
style: const TextStyle(fontSize: 12, color: _orange, fontWeight: FontWeight.w500),
),
showShimmer
? const ShimmerText(
placeholder: '剩余 --- 天',
style: TextStyle(fontSize: 12, color: _orange, fontWeight: FontWeight.w500),
)
: Text(
'剩余 $daysRemaining',
style: const TextStyle(fontSize: 12, color: _orange, fontWeight: FontWeight.w500),
),
const SizedBox(height: 8),
//
Container(

View File

@ -44,6 +44,11 @@ class _TradingPageState extends ConsumerState<TradingPage> {
final user = ref.watch(userNotifierProvider);
final accountSequence = user.accountSequence ?? '';
// Extract loading state and data using shimmer placeholder approach
final isLoading = globalState.isLoading;
final state = globalState.valueOrNull;
final hasError = globalState.hasError;
return Scaffold(
backgroundColor: const Color(0xFFF5F5F5),
body: SafeArea(
@ -57,20 +62,16 @@ class _TradingPageState extends ConsumerState<TradingPage> {
child: SingleChildScrollView(
child: Column(
children: [
//
globalState.when(
data: (state) => _buildPriceCard(state),
loading: () => _buildLoadingCard(),
error: (_, __) => _buildErrorCard('价格加载失败'),
),
// - always render, use shimmer for loading
hasError
? _buildErrorCard('价格加载失败')
: _buildPriceCard(state, isLoading),
// K线图占位区域
_buildChartSection(),
//
globalState.when(
data: (state) => _buildMarketDataCard(state),
loading: () => _buildLoadingCard(),
error: (_, __) => _buildErrorCard('市场数据加载失败'),
),
// - always render, use shimmer for loading
hasError
? _buildErrorCard('市场数据加载失败')
: _buildMarketDataCard(state, isLoading),
// /
_buildTradingPanel(accountSequence),
//
@ -149,10 +150,10 @@ class _TradingPageState extends ConsumerState<TradingPage> {
);
}
Widget _buildPriceCard(state) {
Widget _buildPriceCard(dynamic state, bool isLoading) {
final isPriceUp = state?.isPriceUp ?? true;
final currentPrice = state?.currentPrice ?? '0.000156';
final priceChange = state?.priceChange24h ?? '8.52';
final currentPrice = state?.currentPrice;
final priceChange = state?.priceChange24h;
return Container(
margin: const EdgeInsets.all(16),
@ -176,8 +177,10 @@ class _TradingPageState extends ConsumerState<TradingPage> {
color: _grayText,
),
),
Text(
'= 156.00 绿积分',
DataText(
data: '= 156.00 绿积分',
isLoading: isLoading,
placeholder: '= -- 绿积分',
style: TextStyle(
fontSize: 12,
color: _grayText,
@ -190,8 +193,10 @@ class _TradingPageState extends ConsumerState<TradingPage> {
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
'¥ ${formatPrice(currentPrice)}',
AmountText(
amount: currentPrice != null ? formatPrice(currentPrice) : null,
isLoading: isLoading,
prefix: '\u00A5 ',
style: const TextStyle(
fontSize: 30,
fontWeight: FontWeight.bold,
@ -214,8 +219,10 @@ class _TradingPageState extends ConsumerState<TradingPage> {
size: 16,
color: _green,
),
Text(
'+$priceChange%',
DataText(
data: priceChange != null ? '+$priceChange%' : null,
isLoading: isLoading,
placeholder: '+--.--%',
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
@ -322,7 +329,12 @@ class _TradingPageState extends ConsumerState<TradingPage> {
);
}
Widget _buildMarketDataCard(state) {
Widget _buildMarketDataCard(dynamic state, bool isLoading) {
final sharesPool = state?.sharesPool;
final circulatingPool = state?.circulatingPool;
final greenPointsPool = state?.greenPointsPool;
final blackHoleBurned = state?.blackHoleBurned;
return Container(
margin: const EdgeInsets.all(16),
padding: const EdgeInsets.all(20),
@ -359,10 +371,20 @@ class _TradingPageState extends ConsumerState<TradingPage> {
//
Row(
children: [
_buildMarketDataItem('积分股池', '8,888,888,888', _orange),
_buildMarketDataItem(
'积分股池',
sharesPool ?? '8,888,888,888',
_orange,
isLoading,
),
Container(width: 1, height: 24, color: _bgGray),
const SizedBox(width: 16),
_buildMarketDataItem('流通池', '1,234,567', _orange),
_buildMarketDataItem(
'流通池',
circulatingPool ?? '1,234,567',
_orange,
isLoading,
),
],
),
const SizedBox(height: 24),
@ -371,10 +393,20 @@ class _TradingPageState extends ConsumerState<TradingPage> {
//
Row(
children: [
_buildMarketDataItem('绿积分池', '99,999,999', _orange),
_buildMarketDataItem(
'绿积分池',
greenPointsPool ?? '99,999,999',
_orange,
isLoading,
),
Container(width: 1, height: 24, color: _bgGray),
const SizedBox(width: 16),
_buildMarketDataItem('黑洞销毁量', '50,000,000', _red),
_buildMarketDataItem(
'黑洞销毁量',
blackHoleBurned ?? '50,000,000',
_red,
isLoading,
),
],
),
],
@ -382,7 +414,7 @@ class _TradingPageState extends ConsumerState<TradingPage> {
);
}
Widget _buildMarketDataItem(String label, String value, Color valueColor) {
Widget _buildMarketDataItem(String label, String value, Color valueColor, bool isLoading) {
return Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@ -395,8 +427,10 @@ class _TradingPageState extends ConsumerState<TradingPage> {
),
),
const SizedBox(height: 4),
Text(
value,
DataText(
data: value,
isLoading: isLoading,
placeholder: '--,---,---',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
@ -767,29 +801,6 @@ class _TradingPageState extends ConsumerState<TradingPage> {
);
}
Widget _buildLoadingCard() {
return ShimmerLoading(
child: Container(
margin: const EdgeInsets.all(16),
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: const [
ShimmerBox(width: 100, height: 16),
SizedBox(height: 12),
ShimmerBox(width: 150, height: 28),
SizedBox(height: 8),
ShimmerBox(width: 80, height: 14),
],
),
),
);
}
Widget _buildErrorCard(String message) {
return Container(
margin: const EdgeInsets.all(16),

View File

@ -1,15 +1,147 @@
import 'package:flutter/material.dart';
/// -
/// -
class ShimmerText extends StatefulWidget {
final String placeholder;
final TextStyle? style;
final TextAlign? textAlign;
const ShimmerText({
super.key,
this.placeholder = '--',
this.style,
this.textAlign,
});
@override
State<ShimmerText> createState() => _ShimmerTextState();
}
class _ShimmerTextState extends State<ShimmerText>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _animation;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 800),
vsync: this,
)..repeat(reverse: true);
_animation = Tween<double>(begin: 0.3, end: 1.0).animate(
CurvedAnimation(parent: _controller, curve: Curves.easeInOut),
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _animation,
builder: (context, child) {
return Opacity(
opacity: _animation.value,
child: Text(
widget.placeholder,
style: widget.style,
textAlign: widget.textAlign,
),
);
},
);
}
}
/// -
class DataText extends StatelessWidget {
final String? data;
final bool isLoading;
final String placeholder;
final TextStyle? style;
final TextAlign? textAlign;
final String Function(String)? formatter;
const DataText({
super.key,
this.data,
this.isLoading = false,
this.placeholder = '--',
this.style,
this.textAlign,
this.formatter,
});
@override
Widget build(BuildContext context) {
if (isLoading || data == null) {
return ShimmerText(
placeholder: placeholder,
style: style,
textAlign: textAlign,
);
}
final displayText = formatter != null ? formatter!(data!) : data!;
return Text(
displayText,
style: style,
textAlign: textAlign,
);
}
}
/// -
class AmountText extends StatelessWidget {
final String? amount;
final bool isLoading;
final String prefix;
final String suffix;
final TextStyle? style;
final TextAlign? textAlign;
const AmountText({
super.key,
this.amount,
this.isLoading = false,
this.prefix = '',
this.suffix = '',
this.style,
this.textAlign,
});
@override
Widget build(BuildContext context) {
if (isLoading || amount == null) {
return ShimmerText(
placeholder: '$prefix--$suffix',
style: style,
textAlign: textAlign,
);
}
return Text(
'$prefix$amount$suffix',
style: style,
textAlign: textAlign,
);
}
}
// ============================================================================
// -
// ============================================================================
///
class ShimmerLoading extends StatefulWidget {
final Widget child;
final bool isLoading;
const ShimmerLoading({
super.key,
required this.child,
this.isLoading = true,
});
const ShimmerLoading({super.key, required this.child});
@override
State<ShimmerLoading> createState() => _ShimmerLoadingState();
@ -24,11 +156,11 @@ class _ShimmerLoadingState extends State<ShimmerLoading>
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 1500),
duration: const Duration(milliseconds: 1000),
vsync: this,
)..repeat();
_animation = Tween<double>(begin: -2, end: 2).animate(
CurvedAnimation(parent: _controller, curve: Curves.easeInOutSine),
)..repeat(reverse: true);
_animation = Tween<double>(begin: 0.4, end: 1.0).animate(
CurvedAnimation(parent: _controller, curve: Curves.easeInOut),
);
}
@ -40,29 +172,11 @@ class _ShimmerLoadingState extends State<ShimmerLoading>
@override
Widget build(BuildContext context) {
if (!widget.isLoading) return widget.child;
return AnimatedBuilder(
animation: _animation,
builder: (context, child) {
return ShaderMask(
shaderCallback: (bounds) {
return LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: const [
Color(0xFFEBEBF4),
Color(0xFFF4F4F4),
Color(0xFFEBEBF4),
],
stops: [
_animation.value - 1,
_animation.value,
_animation.value + 1,
].map((e) => e.clamp(0.0, 1.0)).toList(),
).createShader(bounds);
},
blendMode: BlendMode.srcATop,
return Opacity(
opacity: _animation.value,
child: widget.child,
);
},
@ -70,17 +184,17 @@ class _ShimmerLoadingState extends State<ShimmerLoading>
}
}
///
///
class ShimmerBox extends StatelessWidget {
final double? width;
final double height;
final double borderRadius;
final BorderRadius? borderRadius;
const ShimmerBox({
super.key,
this.width,
required this.height,
this.borderRadius = 8,
this.height = 16,
this.borderRadius,
});
@override
@ -90,199 +204,36 @@ class ShimmerBox extends StatelessWidget {
height: height,
decoration: BoxDecoration(
color: const Color(0xFFE5E7EB),
borderRadius: BorderRadius.circular(borderRadius),
borderRadius: borderRadius ?? BorderRadius.circular(4),
),
);
}
}
///
class AssetCardSkeleton extends StatelessWidget {
const AssetCardSkeleton({super.key});
@override
Widget build(BuildContext context) {
return ShimmerLoading(
child: Container(
margin: const EdgeInsets.all(16),
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.04),
blurRadius: 30,
offset: const Offset(0, 8),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const ShimmerBox(width: 80, height: 16),
const SizedBox(height: 12),
const ShimmerBox(width: 180, height: 36),
const SizedBox(height: 8),
const ShimmerBox(width: 120, height: 14),
const SizedBox(height: 12),
const ShimmerBox(width: 100, height: 24, borderRadius: 12),
],
),
),
);
}
}
///
class AssetItemSkeleton extends StatelessWidget {
const AssetItemSkeleton({super.key});
@override
Widget build(BuildContext context) {
return ShimmerLoading(
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 2,
offset: const Offset(0, 1),
),
],
),
child: Row(
children: [
const ShimmerBox(width: 40, height: 40, borderRadius: 20),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const ShimmerBox(width: 60, height: 16),
const SizedBox(height: 8),
const ShimmerBox(width: 100, height: 20),
const SizedBox(height: 4),
const ShimmerBox(width: 80, height: 12),
],
),
),
const ShimmerBox(width: 14, height: 20),
],
),
),
);
}
}
///
class EarningsStatsSkeleton extends StatelessWidget {
const EarningsStatsSkeleton({super.key});
@override
Widget build(BuildContext context) {
return ShimmerLoading(
child: Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 2,
offset: const Offset(0, 1),
),
],
),
child: Column(
children: [
Row(
children: [
Container(
width: 4,
height: 20,
decoration: BoxDecoration(
color: const Color(0xFFE5E7EB),
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(width: 8),
const ShimmerBox(width: 60, height: 16),
],
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: Column(
children: const [
ShimmerBox(width: 50, height: 12),
SizedBox(height: 8),
ShimmerBox(width: 70, height: 16),
],
),
),
Container(
width: 1,
height: 40,
color: const Color(0xFFE5E7EB),
),
Expanded(
child: Column(
children: const [
ShimmerBox(width: 50, height: 12),
SizedBox(height: 8),
ShimmerBox(width: 70, height: 16),
],
),
),
Container(
width: 1,
height: 40,
color: const Color(0xFFE5E7EB),
),
Expanded(
child: Column(
children: const [
ShimmerBox(width: 50, height: 12),
SizedBox(height: 8),
ShimmerBox(width: 70, height: 16),
],
),
),
],
),
],
),
),
);
}
}
///
///
class PageSkeleton extends StatelessWidget {
const PageSkeleton({super.key});
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
children: const [
AssetCardSkeleton(),
SizedBox(height: 24),
AssetItemSkeleton(),
SizedBox(height: 16),
AssetItemSkeleton(),
SizedBox(height: 16),
AssetItemSkeleton(),
SizedBox(height: 24),
EarningsStatsSkeleton(),
],
return ShimmerLoading(
child: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 60),
const ShimmerBox(width: 120, height: 20),
const SizedBox(height: 16),
const ShimmerBox(height: 100),
const SizedBox(height: 24),
const ShimmerBox(height: 80),
const SizedBox(height: 24),
const ShimmerBox(height: 120),
const SizedBox(height: 24),
const ShimmerBox(height: 80),
],
),
),
);
}