This commit is contained in:
sion
2026-04-23 00:44:39 +08:00
parent 685202dd55
commit 8047cfaa76
209 changed files with 2660 additions and 5560 deletions

View File

@@ -6,11 +6,12 @@ import '../../../core/theme/app_color_scheme.dart';
import '../../../core/theme/app_theme_extension.dart';
import '../../../core/theme/app_spacing.dart';
import '../../../data/models/account_models.dart';
import '../../../core/network/api_response.dart';
import '../../../data/services/asset_service.dart';
import '../../../data/services/bonus_service.dart';
import '../../../providers/asset_provider.dart';
import '../../components/coin_icon.dart';
/// 賬單頁面 — 代幣盈虧賬單 + 新人福利賬單 + 推廣福利賬單
/// 賬單頁面 — 充提記錄 + 新人福利 + 推廣福利
class BillsPage extends StatefulWidget {
const BillsPage({super.key});
@@ -22,6 +23,7 @@ class _BillsPageState extends State<BillsPage> with SingleTickerProviderStateMix
late TabController _tabController;
List<AccountTrade> _holdings = [];
List<Map<String, dynamic>> _welfareRecords = [];
List<AccountFlow> _flowRecords = [];
bool _isLoading = true;
@override
@@ -42,10 +44,16 @@ class _BillsPageState extends State<BillsPage> with SingleTickerProviderStateMix
try {
final provider = context.read<AssetProvider>();
final bonusService = context.read<BonusService>();
final assetService = context.read<AssetService>();
// 並行加載持倉和福利記錄
await provider.loadTradeAccount(force: true);
final welfareResponse = await bonusService.getWelfareStatus();
final results = await Future.wait([
provider.loadTradeAccount(force: true),
bonusService.getWelfareStatus(),
assetService.getFlow(pageNum: 1, pageSize: 50),
]);
final welfareResponse = results[1] as ApiResponse;
final flowResponse = results[2] as ApiResponse<Map<String, dynamic>>;
if (mounted) {
setState(() {
@@ -53,6 +61,12 @@ class _BillsPageState extends State<BillsPage> with SingleTickerProviderStateMix
if (welfareResponse.success && welfareResponse.data != null) {
_welfareRecords = _parseWelfareRecords(welfareResponse.data!);
}
if (flowResponse.success && flowResponse.data != null) {
final list = flowResponse.data!['list'] as List<dynamic>? ?? [];
_flowRecords = list
.map((e) => AccountFlow.fromJson(e as Map<String, dynamic>))
.toList();
}
_isLoading = false;
});
}
@@ -69,12 +83,10 @@ class _BillsPageState extends State<BillsPage> with SingleTickerProviderStateMix
List<Map<String, dynamic>> _parseWelfareRecords(Map<String, dynamic> data) {
final records = <Map<String, dynamic>>[];
// 新人福利
final newUser = data['newUserBonus'] as Map<String, dynamic>?;
if (newUser != null) {
final claimed = newUser['claimed'] as bool? ?? false;
final eligible = newUser['eligible'] as bool? ?? false;
// 狀態: 1=已領取, 0=可領取(待領取), 2=不可用(未解鎖)
final int status;
if (claimed) {
status = 1;
@@ -92,7 +104,6 @@ class _BillsPageState extends State<BillsPage> with SingleTickerProviderStateMix
});
}
// 推廣福利列表
final referralRewards = data['referralRewards'] as List<dynamic>? ?? [];
for (var r in referralRewards) {
final map = r as Map<String, dynamic>;
@@ -100,7 +111,6 @@ class _BillsPageState extends State<BillsPage> with SingleTickerProviderStateMix
final milestones = map['milestones'] as List<dynamic>? ?? [];
final claimableCount = map['claimableCount'] as int? ?? 0;
// 每個 milestone 生成一條記錄
for (var m in milestones) {
final ms = m as Map<String, dynamic>;
final earned = ms['earned'] as bool? ?? false;
@@ -109,11 +119,11 @@ class _BillsPageState extends State<BillsPage> with SingleTickerProviderStateMix
final int status;
if (earned) {
status = 1; // 已領取
status = 1;
} else if (claimable) {
status = 0; // 可領取
status = 0;
} else {
status = 2; // 未達標
status = 2;
}
records.add({
'type': 'referral',
@@ -124,7 +134,6 @@ class _BillsPageState extends State<BillsPage> with SingleTickerProviderStateMix
});
}
// 如果沒有 milestone 但有 claimableCount也生成記錄
if (milestones.isEmpty && claimableCount > 0) {
records.add({
'type': 'referral',
@@ -158,13 +167,15 @@ class _BillsPageState extends State<BillsPage> with SingleTickerProviderStateMix
centerTitle: true,
bottom: TabBar(
controller: _tabController,
isScrollable: true,
labelColor: colorScheme.primary,
unselectedLabelColor: colorScheme.onSurfaceVariant,
indicatorColor: colorScheme.primary,
labelStyle: AppTextStyles.headlineMedium(context).copyWith(fontWeight: FontWeight.w600),
unselectedLabelStyle: AppTextStyles.headlineMedium(context),
tabAlignment: TabAlignment.start,
tabs: const [
Tab(text: '代幣盈虧'),
Tab(text: '充提記錄'),
Tab(text: '新人福利'),
Tab(text: '推廣福利'),
],
@@ -175,7 +186,7 @@ class _BillsPageState extends State<BillsPage> with SingleTickerProviderStateMix
: TabBarView(
controller: _tabController,
children: [
_buildCoinProfitTab(),
_buildFlowTab(),
_buildWelfareTab('new_user'),
_buildWelfareTab('referral'),
],
@@ -184,161 +195,261 @@ class _BillsPageState extends State<BillsPage> with SingleTickerProviderStateMix
}
// ============================================
// 代幣盈虧賬單
// 充提記錄
// ============================================
Widget _buildCoinProfitTab() {
final colorScheme = Theme.of(context).colorScheme;
if (_holdings.isEmpty) {
return _buildEmptyState(LucideIcons.wallet, '暫無持倉記錄');
Widget _buildFlowTab() {
if (_flowRecords.isEmpty) {
return _buildEmptyState(LucideIcons.receipt, '暫無流水記錄');
}
// 彙總統計
double totalCost = 0;
double totalValue = 0;
double totalProfit = 0;
for (var h in _holdings) {
totalCost += double.tryParse(h.totalCost) ?? 0;
totalValue += double.tryParse(h.currentValue) ?? 0;
totalProfit += double.tryParse(h.profit) ?? 0;
}
final profitRate = totalCost > 0 ? (totalProfit / totalCost * 100) : 0.0;
final isProfit = totalProfit >= 0;
final profitColor = isProfit ? context.appColors.up : context.appColors.down;
return RefreshIndicator(
onRefresh: _loadData,
child: ListView(
child: ListView.builder(
padding: const EdgeInsets.all(AppSpacing.md),
children: [
// 彙總卡片
Container(
padding: const EdgeInsets.all(AppSpacing.lg),
decoration: BoxDecoration(
color: _isDark ? AppColorScheme.darkSurfaceContainer : AppColorScheme.lightSurfaceLowest,
borderRadius: BorderRadius.circular(AppRadius.xl),
border: Border.all(
color: _isDark
? AppColorScheme.darkOutlineVariant.withValues(alpha: 0.15)
: AppColorScheme.lightOutlineVariant.withValues(alpha: 0.5),
),
),
child: Column(
children: [
Text('總盈虧 (USDT)', style: AppTextStyles.bodyMedium(context).copyWith(
color: colorScheme.onSurfaceVariant,
)),
const SizedBox(height: AppSpacing.xs),
Text(
'${isProfit ? '+' : ''}${totalProfit.toStringAsFixed(2)}',
style: AppTextStyles.displaySmall(context).copyWith(
fontWeight: FontWeight.bold,
color: profitColor,
),
),
const SizedBox(height: AppSpacing.sm),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildSummaryItem('總成本', totalCost.toStringAsFixed(2)),
Container(width: 1, height: 16, color: colorScheme.outlineVariant.withValues(alpha: 0.3)),
_buildSummaryItem('總市值', totalValue.toStringAsFixed(2)),
Container(width: 1, height: 16, color: colorScheme.outlineVariant.withValues(alpha: 0.3)),
_buildSummaryItem('收益率', '${profitRate >= 0 ? '+' : ''}${profitRate.toStringAsFixed(2)}%'),
],
),
],
),
itemCount: _flowRecords.length,
itemBuilder: (context, index) => _buildFlowCard(_flowRecords[index]),
),
);
}
IconData _flowIcon(String flowType) {
switch (flowType) {
case '1':
return LucideIcons.arrowDownToLine;
case '2':
return LucideIcons.arrowUpFromLine;
case '3':
case '4':
return LucideIcons.repeat;
case '5':
return LucideIcons.shoppingCart;
case '6':
return LucideIcons.tag;
case '7':
return LucideIcons.gift;
default:
return LucideIcons.circleDot;
}
}
Color _flowIconColor(String flowType) {
switch (flowType) {
case '1':
case '3':
case '6':
return context.appColors.up;
case '2':
case '4':
case '5':
return context.appColors.down;
case '7':
return Theme.of(context).colorScheme.primary;
default:
return Theme.of(context).colorScheme.onSurfaceVariant;
}
}
Color _flowAmountColor(bool isPositive) {
return isPositive ? context.appColors.up : context.appColors.down;
}
Widget _buildFlowCard(AccountFlow flow) {
final colorScheme = Theme.of(context).colorScheme;
final amount = double.tryParse(flow.amount) ?? 0;
final isPositive = amount >= 0;
final iconColor = _flowIconColor(flow.flowType);
final amountColor = _flowAmountColor(isPositive);
final balanceBefore = double.tryParse(flow.balanceBefore) ?? 0;
final balanceAfter = double.tryParse(flow.balanceAfter) ?? 0;
return GestureDetector(
onTap: () => _showFlowDetail(flow),
child: Container(
margin: const EdgeInsets.only(bottom: AppSpacing.sm),
padding: const EdgeInsets.all(AppSpacing.md),
decoration: BoxDecoration(
color: _isDark ? AppColorScheme.darkSurfaceContainer : AppColorScheme.lightSurfaceLowest,
borderRadius: BorderRadius.circular(AppRadius.lg),
border: Border.all(
color: _isDark
? AppColorScheme.darkOutlineVariant.withValues(alpha: 0.15)
: AppColorScheme.lightOutlineVariant.withValues(alpha: 0.5),
),
const SizedBox(height: AppSpacing.md),
// 各幣種盈虧明細
..._holdings.map((h) => _buildCoinProfitCard(h)),
],
),
);
}
Widget _buildSummaryItem(String label, String value) {
final colorScheme = Theme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
child: Column(
children: [
Text(label, style: AppTextStyles.bodySmall(context).copyWith(
color: colorScheme.onSurfaceVariant,
)),
const SizedBox(height: 2),
Text(value, style: AppTextStyles.labelMedium(context).copyWith(
fontWeight: FontWeight.w600,
)),
],
),
);
}
Widget _buildCoinProfitCard(AccountTrade h) {
final colorScheme = Theme.of(context).colorScheme;
final profit = double.tryParse(h.profit) ?? 0;
final isProfit = profit >= 0;
final profitColor = isProfit ? context.appColors.up : context.appColors.down;
return Container(
margin: const EdgeInsets.only(bottom: AppSpacing.sm),
padding: const EdgeInsets.all(AppSpacing.md),
decoration: BoxDecoration(
color: _isDark ? AppColorScheme.darkSurfaceContainer : AppColorScheme.lightSurfaceLowest,
borderRadius: BorderRadius.circular(AppRadius.lg),
border: Border.all(
color: _isDark
? AppColorScheme.darkOutlineVariant.withValues(alpha: 0.15)
: AppColorScheme.lightOutlineVariant.withValues(alpha: 0.5),
),
),
child: Column(
children: [
// 幣名 + 盈虧金額
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
child: Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: iconColor.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(AppRadius.md),
),
child: Icon(_flowIcon(flow.flowType), size: 18, color: iconColor),
),
const SizedBox(width: AppSpacing.sm),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CoinIcon(symbol: h.coinCode, size: 32),
const SizedBox(width: AppSpacing.sm),
Text(h.coinCode, style: AppTextStyles.headlineMedium(context).copyWith(
fontWeight: FontWeight.bold,
)),
const SizedBox(width: AppSpacing.xs),
Text('x ${double.tryParse(h.quantity)?.toStringAsFixed(4) ?? h.quantity}',
style: AppTextStyles.bodySmall(context).copyWith(color: colorScheme.onSurfaceVariant),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
flow.flowTypeText,
style: AppTextStyles.headlineMedium(context).copyWith(
fontWeight: FontWeight.w600,
),
),
Text(
'${isPositive ? '+' : ''}${amount.toStringAsFixed(2)} ${flow.coinCode}',
style: AppTextStyles.headlineMedium(context).copyWith(
color: amountColor,
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 4),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'餘額 ${balanceBefore.toStringAsFixed(2)}${balanceAfter.toStringAsFixed(2)}',
style: AppTextStyles.bodySmall(context).copyWith(
color: colorScheme.onSurfaceVariant,
),
),
Text(
_formatTime(flow.createTime),
style: AppTextStyles.bodySmall(context).copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
],
),
Text(
'${isProfit ? '+' : ''}${profit.toStringAsFixed(2)} USDT',
style: AppTextStyles.headlineMedium(context).copyWith(
color: profitColor,
fontWeight: FontWeight.bold,
),
const SizedBox(width: 4),
Icon(
LucideIcons.chevronRight,
size: 16,
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4),
),
],
),
),
);
}
void _showFlowDetail(AccountFlow flow) {
final colorScheme = Theme.of(context).colorScheme;
final amount = double.tryParse(flow.amount) ?? 0;
final isPositive = amount >= 0;
final amountColor = _flowAmountColor(isPositive);
final iconColor = _flowIconColor(flow.flowType);
final balanceBefore = double.tryParse(flow.balanceBefore) ?? 0;
final balanceAfter = double.tryParse(flow.balanceAfter) ?? 0;
showModalBottomSheet(
context: context,
backgroundColor: _isDark ? AppColorScheme.darkSurfaceContainer : AppColorScheme.lightSurfaceLowest,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.xl)),
),
builder: (context) => SafeArea(
child: Padding(
padding: const EdgeInsets.all(AppSpacing.lg),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Center(
child: Container(
width: 40,
height: 4,
margin: const EdgeInsets.only(bottom: AppSpacing.md),
decoration: BoxDecoration(
color: colorScheme.outlineVariant.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(2),
),
),
),
Center(
child: Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: iconColor.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(AppRadius.lg),
),
child: Icon(_flowIcon(flow.flowType), size: 24, color: iconColor),
),
),
const SizedBox(height: AppSpacing.md),
Center(
child: Text(
'${isPositive ? '+' : ''}${amount.toStringAsFixed(2)} ${flow.coinCode}',
style: AppTextStyles.displaySmall(context).copyWith(
color: amountColor,
fontWeight: FontWeight.bold,
),
),
),
Center(
child: Text(
flow.flowTypeText,
style: AppTextStyles.bodyMedium(context).copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
const SizedBox(height: AppSpacing.lg),
_buildDetailRow('流水號', flow.flowNo.isNotEmpty ? flow.flowNo : '-'),
_buildDetailRow('幣種', flow.coinCode),
_buildDetailRow(
'變動前餘額',
'${balanceBefore.toStringAsFixed(2)} ${flow.coinCode}',
),
_buildDetailRow(
'變動後餘額',
'${balanceAfter.toStringAsFixed(2)} ${flow.coinCode}',
),
if (flow.relatedOrderNo.isNotEmpty)
_buildDetailRow('關聯訂單', flow.relatedOrderNo),
if (flow.remark.isNotEmpty)
_buildDetailRow('備註', flow.remark),
_buildDetailRow('時間', _formatTimeFull(flow.createTime)),
const SizedBox(height: AppSpacing.md),
],
),
const SizedBox(height: AppSpacing.sm),
// 明細行
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('均價: ${h.avgPrice}', style: AppTextStyles.bodySmall(context).copyWith(
color: colorScheme.onSurfaceVariant,
)),
Text('市值: ${h.currentValue} USDT', style: AppTextStyles.bodySmall(context).copyWith(
color: colorScheme.onSurfaceVariant,
)),
Text(h.formattedProfitRate, style: AppTextStyles.bodySmall(context).copyWith(
color: profitColor,
fontWeight: FontWeight.w600,
)),
],
),
),
);
}
Widget _buildDetailRow(String label, String value) {
final colorScheme = Theme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.symmetric(vertical: AppSpacing.xs),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
label,
style: AppTextStyles.bodyMedium(context).copyWith(
color: colorScheme.onSurfaceVariant,
),
),
Flexible(
child: Text(
value,
style: AppTextStyles.bodyMedium(context).copyWith(
fontWeight: FontWeight.w500,
),
textAlign: TextAlign.right,
),
),
],
),
@@ -375,7 +486,6 @@ class _BillsPageState extends State<BillsPage> with SingleTickerProviderStateMix
final amount = double.tryParse(record['amount']?.toString() ?? '0') ?? 0;
final status = record['status'] as int? ?? 0;
// status: 0=待領取, 1=已領取, 2=未達標
String statusText;
Color statusColor;
switch (status) {
@@ -477,9 +587,18 @@ class _BillsPageState extends State<BillsPage> with SingleTickerProviderStateMix
String _formatTime(dynamic time) {
if (time == null) return '-';
if (time is DateTime) {
return '${time.year}-${time.month.toString().padLeft(2, '0')}-${time.day.toString().padLeft(2, '0')} '
return '${time.month.toString().padLeft(2, '0')}-${time.day.toString().padLeft(2, '0')} '
'${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}';
}
return time.toString();
}
String _formatTimeFull(dynamic time) {
if (time == null) return '-';
if (time is DateTime) {
return '${time.year}-${time.month.toString().padLeft(2, '0')}-${time.day.toString().padLeft(2, '0')} '
'${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}:${time.second.toString().padLeft(2, '0')}';
}
return time.toString();
}
}