598 lines
20 KiB
Dart
598 lines
20 KiB
Dart
import 'package:flutter/material.dart';
|
||
import '../../../../app/theme/app_colors.dart';
|
||
import '../../../../app/i18n/app_localizations.dart';
|
||
import '../../../../core/services/issuer_finance_service.dart';
|
||
|
||
/// 财务管理页面
|
||
///
|
||
/// 法币展示,不暴露链上稳定币细节
|
||
/// 包含:销售收入、Breakage收入、手续费、保证金、冻结款、提现、对账报表
|
||
class FinancePage extends StatelessWidget {
|
||
const FinancePage({super.key});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return DefaultTabController(
|
||
length: 3,
|
||
child: Scaffold(
|
||
appBar: AppBar(
|
||
title: Text(context.t('finance_title')),
|
||
actions: [
|
||
IconButton(
|
||
icon: const Icon(Icons.download_rounded),
|
||
onPressed: () => _showExportDialog(context),
|
||
),
|
||
],
|
||
bottom: TabBar(
|
||
tabs: [
|
||
Tab(text: context.t('finance_tab_overview')),
|
||
Tab(text: context.t('finance_tab_transactions')),
|
||
Tab(text: context.t('finance_tab_reconciliation')),
|
||
],
|
||
),
|
||
),
|
||
body: const TabBarView(
|
||
children: [
|
||
_OverviewTab(),
|
||
_TransactionDetailTab(),
|
||
_ReconciliationTab(),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
void _showExportDialog(BuildContext context) {
|
||
showDialog(
|
||
context: context,
|
||
builder: (ctx) => SimpleDialog(
|
||
title: Text(context.t('finance_export_title')),
|
||
children: [
|
||
SimpleDialogOption(onPressed: () => Navigator.pop(ctx), child: Text(context.t('finance_export_csv'))),
|
||
SimpleDialogOption(onPressed: () => Navigator.pop(ctx), child: Text(context.t('finance_export_excel'))),
|
||
SimpleDialogOption(onPressed: () => Navigator.pop(ctx), child: Text(context.t('finance_export_pdf'))),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _OverviewTab extends StatefulWidget {
|
||
const _OverviewTab();
|
||
|
||
@override
|
||
State<_OverviewTab> createState() => _OverviewTabState();
|
||
}
|
||
|
||
class _OverviewTabState extends State<_OverviewTab> {
|
||
final _financeService = IssuerFinanceService();
|
||
|
||
bool _isLoading = true;
|
||
String? _error;
|
||
FinanceBalance? _balance;
|
||
FinanceStats? _stats;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_loadData();
|
||
}
|
||
|
||
Future<void> _loadData() async {
|
||
setState(() {
|
||
_isLoading = true;
|
||
_error = null;
|
||
});
|
||
|
||
try {
|
||
final results = await Future.wait([
|
||
_financeService.getBalance(),
|
||
_financeService.getStats(),
|
||
]);
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_balance = results[0] as FinanceBalance;
|
||
_stats = results[1] as FinanceStats;
|
||
_isLoading = false;
|
||
});
|
||
} catch (e) {
|
||
debugPrint('[FinancePage] loadData error: $e');
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_isLoading = false;
|
||
_error = e.toString();
|
||
});
|
||
}
|
||
}
|
||
|
||
Future<void> _handleWithdraw() async {
|
||
if (_balance == null || _balance!.withdrawable <= 0) return;
|
||
|
||
final confirmed = await showDialog<bool>(
|
||
context: context,
|
||
builder: (ctx) => AlertDialog(
|
||
title: Text(context.t('finance_confirm_withdraw')),
|
||
content: Text('${context.t('finance_withdraw_amount')}: \$${_balance!.withdrawable.toStringAsFixed(2)}'),
|
||
actions: [
|
||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(context.t('cancel'))),
|
||
ElevatedButton(onPressed: () => Navigator.pop(ctx, true), child: Text(context.t('confirm'))),
|
||
],
|
||
),
|
||
);
|
||
|
||
if (confirmed != true) return;
|
||
|
||
try {
|
||
await _financeService.withdraw(_balance!.withdrawable);
|
||
if (!mounted) return;
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(content: Text(context.t('finance_withdraw_submitted')), backgroundColor: AppColors.success),
|
||
);
|
||
_loadData();
|
||
} catch (e) {
|
||
debugPrint('[FinancePage] withdraw error: $e');
|
||
if (!mounted) return;
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(content: Text('${context.t('finance_withdraw_failed')}: $e'), backgroundColor: AppColors.error),
|
||
);
|
||
}
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
if (_isLoading) {
|
||
return const Center(child: CircularProgressIndicator());
|
||
}
|
||
|
||
if (_error != null) {
|
||
return Center(
|
||
child: Column(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [
|
||
const Icon(Icons.error_outline, size: 48, color: AppColors.textTertiary),
|
||
const SizedBox(height: 16),
|
||
Text(_error!, style: const TextStyle(color: AppColors.textSecondary)),
|
||
const SizedBox(height: 16),
|
||
ElevatedButton(onPressed: _loadData, child: Text(context.t('retry'))),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
return RefreshIndicator(
|
||
onRefresh: _loadData,
|
||
child: SingleChildScrollView(
|
||
physics: const AlwaysScrollableScrollPhysics(),
|
||
padding: const EdgeInsets.all(20),
|
||
child: Column(
|
||
children: [
|
||
// Balance Card
|
||
Container(
|
||
padding: const EdgeInsets.all(20),
|
||
decoration: BoxDecoration(
|
||
gradient: AppColors.primaryGradient,
|
||
borderRadius: BorderRadius.circular(16),
|
||
),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(context.t('finance_withdrawable'), style: TextStyle(fontSize: 13, color: Colors.white.withValues(alpha: 0.7))),
|
||
const SizedBox(height: 4),
|
||
Text(
|
||
'\$${_balance?.withdrawable.toStringAsFixed(2) ?? '0.00'}',
|
||
style: const TextStyle(fontSize: 32, fontWeight: FontWeight.w700, color: Colors.white),
|
||
),
|
||
const SizedBox(height: 16),
|
||
SizedBox(
|
||
width: double.infinity,
|
||
child: ElevatedButton(
|
||
onPressed: _handleWithdraw,
|
||
style: ElevatedButton.styleFrom(
|
||
backgroundColor: Colors.white,
|
||
foregroundColor: AppColors.primary,
|
||
),
|
||
child: Text(context.t('finance_withdraw')),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(height: 20),
|
||
|
||
// Financial Stats
|
||
_buildFinanceStatsGrid(context),
|
||
const SizedBox(height: 20),
|
||
|
||
// Guarantee Fund
|
||
_buildGuaranteeFundCard(context),
|
||
const SizedBox(height: 20),
|
||
|
||
// Revenue Trend
|
||
Container(
|
||
padding: const EdgeInsets.all(16),
|
||
decoration: BoxDecoration(
|
||
color: AppColors.surface,
|
||
borderRadius: BorderRadius.circular(12),
|
||
border: Border.all(color: AppColors.borderLight),
|
||
),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(context.t('finance_revenue_trend'), style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600)),
|
||
const SizedBox(height: 16),
|
||
Container(
|
||
height: 160,
|
||
decoration: BoxDecoration(
|
||
color: AppColors.gray50,
|
||
borderRadius: BorderRadius.circular(8),
|
||
),
|
||
child: Center(child: Text(context.t('finance_revenue_chart'), style: const TextStyle(color: AppColors.textTertiary))),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildFinanceStatsGrid(BuildContext context) {
|
||
final s = _stats;
|
||
final statItems = [
|
||
(context.t('finance_sales_income'), '\$${s?.salesAmount.toStringAsFixed(0) ?? '0'}', AppColors.success),
|
||
(context.t('finance_breakage_income'), '\$${s?.breakageIncome.toStringAsFixed(0) ?? '0'}', AppColors.info),
|
||
(context.t('finance_platform_fee'), '-\$${s?.platformFee.toStringAsFixed(0) ?? '0'}', AppColors.error),
|
||
(context.t('finance_pending_settlement'), '\$${s?.pendingSettlement.toStringAsFixed(0) ?? '0'}', AppColors.warning),
|
||
(context.t('finance_withdrawn'), '\$${s?.withdrawnAmount.toStringAsFixed(0) ?? '0'}', AppColors.textSecondary),
|
||
(context.t('finance_total_income'), '\$${s?.totalRevenue.toStringAsFixed(0) ?? '0'}', AppColors.primary),
|
||
];
|
||
|
||
return GridView.builder(
|
||
shrinkWrap: true,
|
||
physics: const NeverScrollableScrollPhysics(),
|
||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||
crossAxisCount: 2,
|
||
mainAxisSpacing: 12,
|
||
crossAxisSpacing: 12,
|
||
childAspectRatio: 2,
|
||
),
|
||
itemCount: statItems.length,
|
||
itemBuilder: (context, index) {
|
||
final (label, value, color) = statItems[index];
|
||
return Container(
|
||
padding: const EdgeInsets.all(14),
|
||
decoration: BoxDecoration(
|
||
color: AppColors.surface,
|
||
borderRadius: BorderRadius.circular(12),
|
||
border: Border.all(color: AppColors.borderLight),
|
||
),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||
children: [
|
||
Text(label, style: const TextStyle(fontSize: 12, color: AppColors.textSecondary)),
|
||
Text(value, style: TextStyle(fontSize: 17, fontWeight: FontWeight.w700, color: color)),
|
||
],
|
||
),
|
||
);
|
||
},
|
||
);
|
||
}
|
||
|
||
Widget _buildGuaranteeFundCard(BuildContext context) {
|
||
return Container(
|
||
padding: const EdgeInsets.all(16),
|
||
decoration: BoxDecoration(
|
||
color: AppColors.surface,
|
||
borderRadius: BorderRadius.circular(12),
|
||
border: Border.all(color: AppColors.borderLight),
|
||
),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
const Icon(Icons.shield_rounded, color: AppColors.info, size: 20),
|
||
const SizedBox(width: 8),
|
||
Text(context.t('finance_guarantee_title'), style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600)),
|
||
],
|
||
),
|
||
const SizedBox(height: 16),
|
||
_buildRow(context.t('finance_guarantee_deposit'), '\$${_balance?.pending.toStringAsFixed(0) ?? '0'}'),
|
||
_buildRow(context.t('finance_frozen_sales'), '--'),
|
||
_buildRow(context.t('finance_frozen_ratio'), '--'),
|
||
const SizedBox(height: 12),
|
||
SwitchListTile(
|
||
title: Text(context.t('finance_auto_freeze'), style: const TextStyle(fontSize: 14)),
|
||
subtitle: Text(context.t('finance_auto_freeze_desc')),
|
||
value: true,
|
||
onChanged: (_) {
|
||
// TODO: Toggle auto-freeze setting
|
||
},
|
||
activeColor: AppColors.primary,
|
||
contentPadding: EdgeInsets.zero,
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildRow(String label, String value) {
|
||
return Padding(
|
||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||
child: Row(
|
||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||
children: [
|
||
Text(label, style: const TextStyle(fontSize: 13, color: AppColors.textSecondary)),
|
||
Text(value, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500)),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _TransactionDetailTab extends StatefulWidget {
|
||
const _TransactionDetailTab();
|
||
|
||
@override
|
||
State<_TransactionDetailTab> createState() => _TransactionDetailTabState();
|
||
}
|
||
|
||
class _TransactionDetailTabState extends State<_TransactionDetailTab> {
|
||
final _financeService = IssuerFinanceService();
|
||
|
||
bool _isLoading = true;
|
||
String? _error;
|
||
List<TransactionModel> _transactions = [];
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_loadTransactions();
|
||
}
|
||
|
||
Future<void> _loadTransactions() async {
|
||
setState(() {
|
||
_isLoading = true;
|
||
_error = null;
|
||
});
|
||
|
||
try {
|
||
final result = await _financeService.getTransactions();
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_transactions = result.items;
|
||
_isLoading = false;
|
||
});
|
||
} catch (e) {
|
||
debugPrint('[FinancePage] loadTransactions error: $e');
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_isLoading = false;
|
||
_error = e.toString();
|
||
});
|
||
}
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
if (_isLoading) {
|
||
return const Center(child: CircularProgressIndicator());
|
||
}
|
||
|
||
if (_error != null) {
|
||
return Center(
|
||
child: Column(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [
|
||
const Icon(Icons.error_outline, size: 48, color: AppColors.textTertiary),
|
||
const SizedBox(height: 16),
|
||
Text(_error!, style: const TextStyle(color: AppColors.textSecondary)),
|
||
const SizedBox(height: 16),
|
||
ElevatedButton(onPressed: _loadTransactions, child: Text(context.t('retry'))),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
if (_transactions.isEmpty) {
|
||
return Center(
|
||
child: Text(context.t('finance_no_transactions'), style: const TextStyle(color: AppColors.textSecondary)),
|
||
);
|
||
}
|
||
|
||
return RefreshIndicator(
|
||
onRefresh: _loadTransactions,
|
||
child: ListView.separated(
|
||
padding: const EdgeInsets.all(20),
|
||
itemCount: _transactions.length,
|
||
separatorBuilder: (_, __) => const Divider(height: 1),
|
||
itemBuilder: (context, index) {
|
||
final tx = _transactions[index];
|
||
final isPositive = tx.amount >= 0;
|
||
final color = isPositive ? AppColors.success : AppColors.error;
|
||
return ListTile(
|
||
contentPadding: const EdgeInsets.symmetric(vertical: 6),
|
||
title: Text(tx.description, style: const TextStyle(fontSize: 14)),
|
||
subtitle: Text(
|
||
_formatTime(tx.createdAt),
|
||
style: const TextStyle(fontSize: 12, color: AppColors.textTertiary),
|
||
),
|
||
trailing: Text(
|
||
'${isPositive ? '+' : ''}\$${tx.amount.toStringAsFixed(2)}',
|
||
style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: color),
|
||
),
|
||
);
|
||
},
|
||
),
|
||
);
|
||
}
|
||
|
||
String _formatTime(DateTime dt) {
|
||
final now = DateTime.now();
|
||
final isToday = dt.year == now.year && dt.month == now.month && dt.day == now.day;
|
||
final timeStr = '${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}';
|
||
if (isToday) return '${context.t('finance_today')} $timeStr';
|
||
final yesterday = now.subtract(const Duration(days: 1));
|
||
final isYesterday = dt.year == yesterday.year && dt.month == yesterday.month && dt.day == yesterday.day;
|
||
if (isYesterday) return '${context.t('finance_yesterday')} $timeStr';
|
||
return '${dt.month}/${dt.day} $timeStr';
|
||
}
|
||
}
|
||
|
||
class _ReconciliationTab extends StatefulWidget {
|
||
const _ReconciliationTab();
|
||
|
||
@override
|
||
State<_ReconciliationTab> createState() => _ReconciliationTabState();
|
||
}
|
||
|
||
class _ReconciliationTabState extends State<_ReconciliationTab> {
|
||
final _financeService = IssuerFinanceService();
|
||
|
||
bool _isLoading = true;
|
||
String? _error;
|
||
Map<String, dynamic> _reconciliation = {};
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_loadReconciliation();
|
||
}
|
||
|
||
Future<void> _loadReconciliation() async {
|
||
setState(() {
|
||
_isLoading = true;
|
||
_error = null;
|
||
});
|
||
|
||
try {
|
||
final data = await _financeService.getReconciliation();
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_reconciliation = data;
|
||
_isLoading = false;
|
||
});
|
||
} catch (e) {
|
||
debugPrint('[FinancePage] loadReconciliation error: $e');
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_isLoading = false;
|
||
_error = e.toString();
|
||
});
|
||
}
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
if (_isLoading) {
|
||
return const Center(child: CircularProgressIndicator());
|
||
}
|
||
|
||
if (_error != null) {
|
||
return Center(
|
||
child: Column(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [
|
||
const Icon(Icons.error_outline, size: 48, color: AppColors.textTertiary),
|
||
const SizedBox(height: 16),
|
||
Text(_error!, style: const TextStyle(color: AppColors.textSecondary)),
|
||
const SizedBox(height: 16),
|
||
ElevatedButton(onPressed: _loadReconciliation, child: Text(context.t('retry'))),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
final reports = (_reconciliation['reports'] as List?)?.cast<Map<String, dynamic>>() ?? [];
|
||
|
||
return RefreshIndicator(
|
||
onRefresh: _loadReconciliation,
|
||
child: ListView(
|
||
physics: const AlwaysScrollableScrollPhysics(),
|
||
padding: const EdgeInsets.all(20),
|
||
children: [
|
||
// Generate New
|
||
OutlinedButton.icon(
|
||
onPressed: () {
|
||
// TODO: Trigger reconciliation report generation
|
||
},
|
||
icon: const Icon(Icons.add_rounded),
|
||
label: Text(context.t('finance_generate_report')),
|
||
style: OutlinedButton.styleFrom(
|
||
minimumSize: const Size(double.infinity, 48),
|
||
),
|
||
),
|
||
const SizedBox(height: 20),
|
||
|
||
if (reports.isEmpty)
|
||
Center(
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(32),
|
||
child: Text(context.t('finance_no_reports'), style: const TextStyle(color: AppColors.textSecondary)),
|
||
),
|
||
)
|
||
else
|
||
...reports.map((r) {
|
||
final title = r['title'] ?? '';
|
||
final summary = r['summary'] ?? '';
|
||
final status = r['status'] ?? '--';
|
||
return Container(
|
||
margin: const EdgeInsets.only(bottom: 12),
|
||
padding: const EdgeInsets.all(16),
|
||
decoration: BoxDecoration(
|
||
color: AppColors.surface,
|
||
borderRadius: BorderRadius.circular(12),
|
||
border: Border.all(color: AppColors.borderLight),
|
||
),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||
children: [
|
||
Expanded(
|
||
child: Text(title, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600)),
|
||
),
|
||
Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||
decoration: BoxDecoration(
|
||
color: AppColors.successLight,
|
||
borderRadius: BorderRadius.circular(999),
|
||
),
|
||
child: Text(status, style: const TextStyle(fontSize: 11, color: AppColors.success)),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 6),
|
||
Text(summary, style: const TextStyle(fontSize: 12, color: AppColors.textSecondary)),
|
||
const SizedBox(height: 12),
|
||
Row(
|
||
children: [
|
||
TextButton.icon(
|
||
onPressed: () {
|
||
// TODO: Navigate to reconciliation detail view
|
||
},
|
||
icon: const Icon(Icons.visibility_rounded, size: 16),
|
||
label: Text(context.t('view')),
|
||
),
|
||
TextButton.icon(
|
||
onPressed: () {
|
||
// TODO: Export reconciliation report
|
||
},
|
||
icon: const Icon(Icons.download_rounded, size: 16),
|
||
label: Text(context.t('export')),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|