Files
monisuo/flutter_monisuo/lib/ui/pages/trade/trade_page.dart
2026-04-07 02:02:14 +08:00

297 lines
9.7 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'package:flutter/material.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import 'package:provider/provider.dart';
import '../../../core/theme/app_spacing.dart';
import '../../../core/theme/app_theme_extension.dart';
import '../../../data/models/coin.dart';
import '../../../providers/market_provider.dart';
import '../../../providers/asset_provider.dart';
import '../../../data/services/trade_service.dart';
import '../../components/neon_glow.dart';
import 'components/coin_selector.dart';
import 'components/price_card.dart';
import 'components/placeholder_card.dart';
import 'components/trade_form_card.dart';
import 'components/trade_button.dart';
import 'components/confirm_dialog.dart';
/// 交易頁面
///
/// 設計稿 Trade 頁面,佈局結構:
/// - 幣種選擇器卡片Coin Selector Card
/// - 價格卡片Price Card大號價格 + 漲跌幅徽章 + 副標題
/// - 買入/賣出切換Buy/Sell Toggle
/// - 交易表單卡片Trade Form Card金額輸入 + 快捷比例 + 計算數量
/// - CTA 買入/賣出按鈕Buy/Sell Button
class TradePage extends StatefulWidget {
final String? initialCoinCode;
const TradePage({super.key, this.initialCoinCode});
@override
State<TradePage> createState() => _TradePageState();
}
class _TradePageState extends State<TradePage>
with AutomaticKeepAliveClientMixin {
int _tradeType = 0; // 0=買入, 1=賣出
Coin? _selectedCoin;
final _amountController = TextEditingController();
bool _isSubmitting = false;
@override
bool get wantKeepAlive => true;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => _loadData());
}
void _loadData() {
final marketProvider = context.read<MarketProvider>();
marketProvider.loadCoins().then((_) {
if (widget.initialCoinCode != null && _selectedCoin == null) {
final coins = marketProvider.allCoins;
final coin = coins.firstWhere(
(c) =>
c.code.toUpperCase() == widget.initialCoinCode!.toUpperCase(),
orElse: () =>
coins.isNotEmpty ? coins.first : throw Exception('No coins'),
);
if (mounted) setState(() => _selectedCoin = coin);
}
});
context.read<AssetProvider>().refreshAll(force: true);
}
@override
void dispose() {
_amountController.dispose();
super.dispose();
}
/// 獲取交易賬戶中 USDT 可用餘額
String get _availableUsdt {
final holdings = context.read<AssetProvider>().holdings;
final usdt = holdings.where((h) => h.coinCode == 'USDT').firstOrNull;
return usdt?.quantity ?? '0';
}
/// 獲取交易賬戶中當前幣種的持倉數量
String get _availableCoinQty {
if (_selectedCoin == null) return '0';
final holdings = context.read<AssetProvider>().holdings;
final pos = holdings
.where((h) => h.coinCode == _selectedCoin!.code)
.firstOrNull;
return pos?.quantity ?? '0';
}
/// 計算可買入/賣出的最大 USDT 金額
String get _maxAmount {
if (_selectedCoin == null) return '0';
final price = _selectedCoin!.price;
if (price <= 0) return '0';
if (_tradeType == 0) {
return _availableUsdt;
} else {
final qty = double.tryParse(_availableCoinQty) ?? 0;
return (qty * price).toStringAsFixed(2);
}
}
/// 計算數量
String get _calculatedQuantity {
final amount = double.tryParse(_amountController.text) ?? 0;
final price = _selectedCoin?.price ?? 0;
if (price <= 0 || amount <= 0) return '0';
// 向下截斷到4位小數避免回算超出金額
final qty = amount / price;
return ((qty * 10000).truncateToDouble() / 10000).toStringAsFixed(4);
}
@override
Widget build(BuildContext context) {
super.build(context);
return Scaffold(
backgroundColor: context.colors.background,
body: Consumer2<MarketProvider, AssetProvider>(
builder: (context, market, asset, _) {
return SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(
AppSpacing.md, AppSpacing.md, AppSpacing.md, AppSpacing.xl + AppSpacing.sm, // 添加頂部間距
),
child: Column(
children: [
// 幣種選擇器卡片
CoinSelector(
selectedCoin: _selectedCoin,
coins: market.allCoins
.where((c) =>
c.code != 'USDT' &&
c.code != 'BTC' &&
c.code != 'ETH')
.toList(),
onCoinSelected: (coin) {
setState(() {
_selectedCoin = coin;
_amountController.clear();
});
},
),
const SizedBox(height: AppSpacing.md),
// 價格卡片
if (_selectedCoin != null)
PriceCard(coin: _selectedCoin!)
else
PlaceholderCard(
message: '請先選擇交易幣種',
),
const SizedBox(height: AppSpacing.md),
// 交易表單卡片(內含買入/賣出切換 + 表單)
TradeFormCard(
tradeType: _tradeType,
selectedCoin: _selectedCoin,
amountController: _amountController,
availableUsdt: _availableUsdt,
availableCoinQty: _availableCoinQty,
calculatedQuantity: _calculatedQuantity,
maxAmount: _maxAmount,
onTradeTypeChanged: (type) => setState(() {
_tradeType = type;
_amountController.clear();
}),
onAmountChanged: () => setState(() {}),
onFillPercent: (pct) => _fillPercent(pct),
),
const SizedBox(height: AppSpacing.md),
// CTA 買入/賣出按鈕
SizedBox(
width: double.infinity,
height: 48,
child: TradeButton(
isBuy: _tradeType == 0,
coinCode: _selectedCoin?.code,
enabled: _canTrade() && !_isSubmitting,
isLoading: _isSubmitting,
onPressed: _executeTrade,
),
),
],
),
),
);
},
),
);
}
bool _canTrade() {
if (_selectedCoin == null) return false;
final amount = double.tryParse(_amountController.text) ?? 0;
if (amount <= 0) return false;
// 買入時校驗不超過可用USDT
if (_tradeType == 0) {
final available = double.tryParse(_availableUsdt) ?? 0;
if (amount > available) return false;
}
return true;
}
void _fillPercent(double pct) {
final max = double.tryParse(_maxAmount) ?? 0;
final value = max * pct;
// 向下截斷到2位小數避免四捨五入超出可用餘額
// 向下截斷到2位小數再減0.01作為安全緩衝,避免精度問題導致餘額不足
final truncated = ((value * 100).truncateToDouble() / 100);
final safe = truncated > 0.01 ? truncated - 0.01 : truncated;
_amountController.text = safe.toStringAsFixed(2);
setState(() {});
}
void _executeTrade() async {
final isBuy = _tradeType == 0;
final amount = _amountController.text;
final quantity = _calculatedQuantity;
final price = _selectedCoin!.price.toStringAsFixed(2);
final coinCode = _selectedCoin!.code;
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => ConfirmDialog(
isBuy: isBuy,
coinCode: coinCode,
price: price,
quantity: quantity,
amount: amount,
),
);
if (confirmed != true) return;
setState(() => _isSubmitting = true);
try {
final tradeService = context.read<TradeService>();
final response = isBuy
? await tradeService.buy(
coinCode: coinCode, price: price, quantity: quantity)
: await tradeService.sell(
coinCode: coinCode, price: price, quantity: quantity);
if (!mounted) return;
if (response.success) {
_amountController.clear();
// 刷新資產數據
context.read<AssetProvider>().refreshAll(force: true);
_showResultDialog(true, '${isBuy ? '買入' : '賣出'}成功',
'$quantity $coinCode @ $price USDT');
} else {
_showResultDialog(false, '交易失敗', response.message ?? '請稍後重試');
}
} catch (e) {
if (mounted) {
_showResultDialog(false, '交易失敗', e.toString());
}
} finally {
if (mounted) setState(() => _isSubmitting = false);
}
}
void _showResultDialog(bool success, String title, String message) {
showShadDialog(
context: context,
builder: (ctx) => ShadDialog.alert(
title: Row(
children: [
NeonIcon(
icon: success ? Icons.check_circle : Icons.error,
color: success
? ctx.appColors.up
: ctx.colors.error,
size: 24,
),
SizedBox(width: AppSpacing.sm),
Text(title),
],
),
description: Text(message),
actions: [
ShadButton(
child: const Text('確定'),
onPressed: () => Navigator.of(ctx).pop(),
),
],
),
);
}
}