Files
monisuo/flutter_monisuo/lib/ui/pages/home/bills_page.dart
2026-04-23 00:44:39 +08:00

605 lines
20 KiB
Dart

import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:provider/provider.dart';
import '../../../core/theme/app_theme.dart';
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';
/// 賬單頁面 — 充提記錄 + 新人福利 + 推廣福利
class BillsPage extends StatefulWidget {
const BillsPage({super.key});
@override
State<BillsPage> createState() => _BillsPageState();
}
class _BillsPageState extends State<BillsPage> with SingleTickerProviderStateMixin {
late TabController _tabController;
List<AccountTrade> _holdings = [];
List<Map<String, dynamic>> _welfareRecords = [];
List<AccountFlow> _flowRecords = [];
bool _isLoading = true;
@override
void initState() {
super.initState();
_tabController = TabController(length: 3, vsync: this);
WidgetsBinding.instance.addPostFrameCallback((_) => _loadData());
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
Future<void> _loadData() async {
setState(() => _isLoading = true);
try {
final provider = context.read<AssetProvider>();
final bonusService = context.read<BonusService>();
final assetService = context.read<AssetService>();
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(() {
_holdings = provider.holdings;
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;
});
}
} catch (_) {
if (mounted) {
setState(() {
_holdings = context.read<AssetProvider>().holdings;
_isLoading = false;
});
}
}
}
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;
final int status;
if (claimed) {
status = 1;
} else if (eligible) {
status = 0;
} else {
status = 2;
}
records.add({
'type': 'new_user',
'title': '新人福利',
'amount': newUser['amount']?.toString() ?? '100.00',
'status': status,
'time': newUser['claimTime'] ?? newUser['createTime'],
});
}
final referralRewards = data['referralRewards'] as List<dynamic>? ?? [];
for (var r in referralRewards) {
final map = r as Map<String, dynamic>;
final username = map['username'] as String? ?? '用戶';
final milestones = map['milestones'] as List<dynamic>? ?? [];
final claimableCount = map['claimableCount'] as int? ?? 0;
for (var m in milestones) {
final ms = m as Map<String, dynamic>;
final earned = ms['earned'] as bool? ?? false;
final claimable = ms['claimable'] as bool? ?? false;
final milestoneVal = ms['milestone'] as int? ?? 1;
final int status;
if (earned) {
status = 1;
} else if (claimable) {
status = 0;
} else {
status = 2;
}
records.add({
'type': 'referral',
'title': '推廣福利 - $username (${milestoneVal}000)',
'amount': '100.00',
'status': status,
'time': ms['claimTime'] ?? ms['createTime'],
});
}
if (milestones.isEmpty && claimableCount > 0) {
records.add({
'type': 'referral',
'title': '推廣福利 - $username',
'amount': '${claimableCount * 100}',
'status': 0,
'time': null,
});
}
}
return records;
}
bool get _isDark => Theme.of(context).brightness == Brightness.dark;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Scaffold(
backgroundColor: _isDark ? AppColorScheme.darkBackground : AppColorScheme.lightBackground,
appBar: AppBar(
leading: IconButton(
icon: const Icon(LucideIcons.arrowLeft, size: 20),
onPressed: () => Navigator.of(context).pop(),
),
title: Text('我的賬單', style: AppTextStyles.headlineLarge(context)),
backgroundColor: _isDark ? AppColorScheme.darkBackground : AppColorScheme.lightBackground,
elevation: 0,
scrolledUnderElevation: 0,
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: '推廣福利'),
],
),
),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: TabBarView(
controller: _tabController,
children: [
_buildFlowTab(),
_buildWelfareTab('new_user'),
_buildWelfareTab('referral'),
],
),
);
}
// ============================================
// 充提記錄
// ============================================
Widget _buildFlowTab() {
if (_flowRecords.isEmpty) {
return _buildEmptyState(LucideIcons.receipt, '暫無流水記錄');
}
return RefreshIndicator(
onRefresh: _loadData,
child: ListView.builder(
padding: const EdgeInsets.all(AppSpacing.md),
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),
),
),
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: [
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,
),
),
],
),
],
),
),
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),
],
),
),
),
);
}
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,
),
),
],
),
);
}
// ============================================
// 福利賬單
// ============================================
Widget _buildWelfareTab(String type) {
final records = _welfareRecords
.where((r) => r['type'] == type && r['status'] == 1)
.toList();
if (records.isEmpty) {
return _buildEmptyState(
LucideIcons.gift,
type == 'new_user' ? '暫無新人福利記錄' : '暫無推廣福利記錄',
);
}
return RefreshIndicator(
onRefresh: _loadData,
child: ListView.builder(
padding: const EdgeInsets.all(AppSpacing.md),
itemCount: records.length,
itemBuilder: (context, index) => _buildWelfareCard(records[index]),
),
);
}
Widget _buildWelfareCard(Map<String, dynamic> record) {
final colorScheme = Theme.of(context).colorScheme;
final amount = double.tryParse(record['amount']?.toString() ?? '0') ?? 0;
final status = record['status'] as int? ?? 0;
String statusText;
Color statusColor;
switch (status) {
case 1:
statusText = '已領取';
statusColor = context.appColors.up;
break;
case 2:
statusText = '未達標';
statusColor = colorScheme.onSurfaceVariant;
break;
default:
statusText = '待領取';
statusColor = AppColorScheme.warning;
}
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: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(record['title'] ?? '', style: AppTextStyles.headlineMedium(context).copyWith(
fontWeight: FontWeight.bold,
)),
const SizedBox(height: AppSpacing.xs),
if (record['time'] != null)
Text(
_formatTime(record['time']),
style: AppTextStyles.bodySmall(context).copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'+${amount.toStringAsFixed(2)} USDT',
style: AppTextStyles.headlineMedium(context).copyWith(
color: context.appColors.up,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 2),
Container(
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.sm, vertical: 2),
decoration: BoxDecoration(
color: statusColor.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(AppRadius.sm),
),
child: Text(statusText, style: AppTextStyles.bodySmall(context).copyWith(
color: statusColor,
fontWeight: FontWeight.w600,
fontSize: 11,
)),
),
],
),
],
),
);
}
// ============================================
// 通用組件
// ============================================
Widget _buildEmptyState(IconData icon, String text) {
final colorScheme = Theme.of(context).colorScheme;
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, size: 64, color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4)),
const SizedBox(height: AppSpacing.md),
Text(text, style: AppTextStyles.headlineMedium(context).copyWith(
color: colorScheme.onSurfaceVariant,
)),
],
),
);
}
String _formatTime(dynamic time) {
if (time == null) return '-';
if (time is DateTime) {
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();
}
}