2026-03-22 00:21:21 +08:00
|
|
|
|
import 'package:flutter/material.dart';
|
2026-04-08 12:24:24 +08:00
|
|
|
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
2026-03-22 00:21:21 +08:00
|
|
|
|
import 'package:provider/provider.dart';
|
2026-03-23 15:37:59 +08:00
|
|
|
|
import '../../../core/theme/app_spacing.dart';
|
2026-04-06 01:58:08 +08:00
|
|
|
|
import '../../../core/theme/app_theme_extension.dart';
|
2026-03-22 00:21:21 +08:00
|
|
|
|
import '../../../data/models/coin.dart';
|
|
|
|
|
|
import '../../../providers/market_provider.dart';
|
|
|
|
|
|
import '../../../providers/asset_provider.dart';
|
2026-03-28 23:57:51 +08:00
|
|
|
|
import '../../../data/services/trade_service.dart';
|
2026-03-24 02:16:19 +08:00
|
|
|
|
import '../../components/neon_glow.dart';
|
2026-04-05 22:38:56 +08:00
|
|
|
|
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';
|
2026-03-22 00:21:21 +08:00
|
|
|
|
|
2026-04-07 01:05:05 +08:00
|
|
|
|
/// 交易頁面
|
2026-04-05 22:24:04 +08:00
|
|
|
|
///
|
2026-04-07 01:05:05 +08:00
|
|
|
|
/// 設計稿 Trade 頁面,佈局結構:
|
|
|
|
|
|
/// - 幣種選擇器卡片(Coin Selector Card)
|
|
|
|
|
|
/// - 價格卡片(Price Card):大號價格 + 漲跌幅徽章 + 副標題
|
|
|
|
|
|
/// - 買入/賣出切換(Buy/Sell Toggle)
|
|
|
|
|
|
/// - 交易表單卡片(Trade Form Card):金額輸入 + 快捷比例 + 計算數量
|
|
|
|
|
|
/// - CTA 買入/賣出按鈕(Buy/Sell Button)
|
2026-03-22 00:21:21 +08:00
|
|
|
|
class TradePage extends StatefulWidget {
|
2026-03-25 23:59:50 +08:00
|
|
|
|
final String? initialCoinCode;
|
|
|
|
|
|
|
|
|
|
|
|
const TradePage({super.key, this.initialCoinCode});
|
2026-03-22 00:21:21 +08:00
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
|
State<TradePage> createState() => _TradePageState();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-28 23:57:51 +08:00
|
|
|
|
class _TradePageState extends State<TradePage>
|
|
|
|
|
|
with AutomaticKeepAliveClientMixin {
|
2026-04-07 01:05:05 +08:00
|
|
|
|
int _tradeType = 0; // 0=買入, 1=賣出
|
2026-03-22 00:21:21 +08:00
|
|
|
|
Coin? _selectedCoin;
|
2026-03-28 23:57:51 +08:00
|
|
|
|
final _amountController = TextEditingController();
|
|
|
|
|
|
bool _isSubmitting = false;
|
2026-03-22 00:21:21 +08:00
|
|
|
|
|
2026-03-24 02:16:19 +08:00
|
|
|
|
@override
|
|
|
|
|
|
bool get wantKeepAlive => true;
|
|
|
|
|
|
|
2026-03-22 00:21:21 +08:00
|
|
|
|
@override
|
|
|
|
|
|
void initState() {
|
|
|
|
|
|
super.initState();
|
2026-03-23 00:43:19 +08:00
|
|
|
|
WidgetsBinding.instance.addPostFrameCallback((_) => _loadData());
|
2026-03-22 00:21:21 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void _loadData() {
|
2026-03-25 23:59:50 +08:00
|
|
|
|
final marketProvider = context.read<MarketProvider>();
|
|
|
|
|
|
marketProvider.loadCoins().then((_) {
|
|
|
|
|
|
if (widget.initialCoinCode != null && _selectedCoin == null) {
|
|
|
|
|
|
final coins = marketProvider.allCoins;
|
|
|
|
|
|
final coin = coins.firstWhere(
|
2026-03-28 23:57:51 +08:00
|
|
|
|
(c) =>
|
|
|
|
|
|
c.code.toUpperCase() == widget.initialCoinCode!.toUpperCase(),
|
|
|
|
|
|
orElse: () =>
|
|
|
|
|
|
coins.isNotEmpty ? coins.first : throw Exception('No coins'),
|
2026-03-25 23:59:50 +08:00
|
|
|
|
);
|
2026-03-28 23:57:51 +08:00
|
|
|
|
if (mounted) setState(() => _selectedCoin = coin);
|
2026-03-25 23:59:50 +08:00
|
|
|
|
}
|
|
|
|
|
|
});
|
2026-03-28 23:57:51 +08:00
|
|
|
|
context.read<AssetProvider>().refreshAll(force: true);
|
2026-03-22 00:21:21 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
|
void dispose() {
|
2026-03-28 23:57:51 +08:00
|
|
|
|
_amountController.dispose();
|
2026-03-22 00:21:21 +08:00
|
|
|
|
super.dispose();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-07 01:05:05 +08:00
|
|
|
|
/// 獲取交易賬戶中 USDT 可用餘額
|
2026-03-28 23:57:51 +08:00
|
|
|
|
String get _availableUsdt {
|
|
|
|
|
|
final holdings = context.read<AssetProvider>().holdings;
|
|
|
|
|
|
final usdt = holdings.where((h) => h.coinCode == 'USDT').firstOrNull;
|
|
|
|
|
|
return usdt?.quantity ?? '0';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-07 01:05:05 +08:00
|
|
|
|
/// 獲取交易賬戶中當前幣種的持倉數量
|
2026-03-28 23:57:51 +08:00
|
|
|
|
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';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-07 01:05:05 +08:00
|
|
|
|
/// 計算可買入/賣出的最大 USDT 金額
|
2026-03-28 23:57:51 +08:00
|
|
|
|
String get _maxAmount {
|
|
|
|
|
|
if (_selectedCoin == null) return '0';
|
|
|
|
|
|
final price = _selectedCoin!.price;
|
|
|
|
|
|
if (price <= 0) return '0';
|
|
|
|
|
|
|
|
|
|
|
|
if (_tradeType == 0) {
|
|
|
|
|
|
return _availableUsdt;
|
|
|
|
|
|
} else {
|
2026-04-08 01:09:57 +08:00
|
|
|
|
// 賣出:qty * price 截斷到2位
|
2026-03-28 23:57:51 +08:00
|
|
|
|
final qty = double.tryParse(_availableCoinQty) ?? 0;
|
2026-04-08 01:09:57 +08:00
|
|
|
|
return ((qty * price * 100).truncateToDouble() / 100).toStringAsFixed(2);
|
2026-03-28 23:57:51 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-08 01:09:57 +08:00
|
|
|
|
/// 計算數量(向下截斷到4位小數,確保 price * quantity <= amount)
|
2026-03-28 23:57:51 +08:00
|
|
|
|
String get _calculatedQuantity {
|
|
|
|
|
|
final amount = double.tryParse(_amountController.text) ?? 0;
|
|
|
|
|
|
final price = _selectedCoin?.price ?? 0;
|
|
|
|
|
|
if (price <= 0 || amount <= 0) return '0';
|
2026-04-08 01:09:57 +08:00
|
|
|
|
// 使用與後端一致的截斷邏輯:先算原始數量,截斷到4位,再回算金額確保不超
|
|
|
|
|
|
final rawQty = amount / price;
|
|
|
|
|
|
final truncatedQty = (rawQty * 10000).truncateToDouble() / 10000;
|
|
|
|
|
|
// 回算:roundedPrice * truncatedQty,確保不超過 amount
|
|
|
|
|
|
final roundedPrice = (price * 100).truncateToDouble() / 100;
|
|
|
|
|
|
if (roundedPrice * truncatedQty > amount) {
|
|
|
|
|
|
// 回退一個最小單位(0.0001)
|
|
|
|
|
|
return (truncatedQty - 0.0001).toStringAsFixed(4);
|
|
|
|
|
|
}
|
|
|
|
|
|
return truncatedQty.toStringAsFixed(4);
|
2026-03-28 23:57:51 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-22 00:21:21 +08:00
|
|
|
|
@override
|
|
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
|
|
super.build(context);
|
2026-03-22 04:50:19 +08:00
|
|
|
|
|
2026-03-22 00:21:21 +08:00
|
|
|
|
return Scaffold(
|
2026-04-08 12:24:24 +08:00
|
|
|
|
backgroundColor: Theme.of(context).colorScheme.surface,
|
2026-03-22 00:21:21 +08:00
|
|
|
|
body: Consumer2<MarketProvider, AssetProvider>(
|
|
|
|
|
|
builder: (context, market, asset, _) {
|
2026-04-05 22:24:04 +08:00
|
|
|
|
return SafeArea(
|
|
|
|
|
|
child: SingleChildScrollView(
|
|
|
|
|
|
padding: const EdgeInsets.fromLTRB(
|
2026-04-07 01:05:05 +08:00
|
|
|
|
AppSpacing.md, AppSpacing.md, AppSpacing.md, AppSpacing.xl + AppSpacing.sm, // 添加頂部間距
|
2026-04-05 22:24:04 +08:00
|
|
|
|
),
|
|
|
|
|
|
child: Column(
|
|
|
|
|
|
children: [
|
2026-04-07 01:05:05 +08:00
|
|
|
|
// 幣種選擇器卡片
|
2026-04-05 22:38:56 +08:00
|
|
|
|
CoinSelector(
|
2026-04-05 22:24:04 +08:00
|
|
|
|
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),
|
|
|
|
|
|
|
2026-04-07 01:05:05 +08:00
|
|
|
|
// 價格卡片
|
2026-04-05 22:24:04 +08:00
|
|
|
|
if (_selectedCoin != null)
|
2026-04-05 22:38:56 +08:00
|
|
|
|
PriceCard(coin: _selectedCoin!)
|
2026-04-05 22:24:04 +08:00
|
|
|
|
else
|
2026-04-05 22:38:56 +08:00
|
|
|
|
PlaceholderCard(
|
2026-04-07 01:05:05 +08:00
|
|
|
|
message: '請先選擇交易幣種',
|
2026-03-28 23:57:51 +08:00
|
|
|
|
),
|
2026-04-05 22:24:04 +08:00
|
|
|
|
const SizedBox(height: AppSpacing.md),
|
|
|
|
|
|
|
2026-04-07 01:05:05 +08:00
|
|
|
|
// 交易表單卡片(內含買入/賣出切換 + 表單)
|
2026-04-05 22:38:56 +08:00
|
|
|
|
TradeFormCard(
|
2026-04-05 22:24:04 +08:00
|
|
|
|
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),
|
|
|
|
|
|
|
2026-04-07 01:05:05 +08:00
|
|
|
|
// CTA 買入/賣出按鈕
|
2026-04-05 22:24:04 +08:00
|
|
|
|
SizedBox(
|
|
|
|
|
|
width: double.infinity,
|
|
|
|
|
|
height: 48,
|
2026-04-05 22:38:56 +08:00
|
|
|
|
child: TradeButton(
|
2026-04-05 22:24:04 +08:00
|
|
|
|
isBuy: _tradeType == 0,
|
|
|
|
|
|
coinCode: _selectedCoin?.code,
|
|
|
|
|
|
enabled: _canTrade() && !_isSubmitting,
|
|
|
|
|
|
isLoading: _isSubmitting,
|
|
|
|
|
|
onPressed: _executeTrade,
|
2026-03-28 23:57:51 +08:00
|
|
|
|
),
|
2026-04-05 22:24:04 +08:00
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
2026-03-22 00:21:21 +08:00
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
},
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-28 23:57:51 +08:00
|
|
|
|
bool _canTrade() {
|
|
|
|
|
|
if (_selectedCoin == null) return false;
|
|
|
|
|
|
final amount = double.tryParse(_amountController.text) ?? 0;
|
2026-03-30 00:30:42 +08:00
|
|
|
|
if (amount <= 0) return false;
|
2026-04-07 01:05:05 +08:00
|
|
|
|
// 買入時校驗不超過可用USDT
|
2026-03-30 00:30:42 +08:00
|
|
|
|
if (_tradeType == 0) {
|
|
|
|
|
|
final available = double.tryParse(_availableUsdt) ?? 0;
|
|
|
|
|
|
if (amount > available) return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
return true;
|
2026-03-28 23:57:51 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void _fillPercent(double pct) {
|
|
|
|
|
|
final max = double.tryParse(_maxAmount) ?? 0;
|
2026-04-07 01:32:32 +08:00
|
|
|
|
final value = max * pct;
|
2026-04-08 01:09:57 +08:00
|
|
|
|
|
|
|
|
|
|
if (_tradeType == 0) {
|
|
|
|
|
|
// 買入:向下截斷到2位小數
|
|
|
|
|
|
_amountController.text =
|
|
|
|
|
|
((value * 100).truncateToDouble() / 100).toStringAsFixed(2);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 賣出:_maxAmount 已是 qty*price 的四捨五入值,直接截斷
|
|
|
|
|
|
_amountController.text =
|
|
|
|
|
|
((value * 100).truncateToDouble() / 100).toStringAsFixed(2);
|
|
|
|
|
|
}
|
2026-03-28 23:57:51 +08:00
|
|
|
|
setState(() {});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void _executeTrade() async {
|
2026-03-23 00:43:19 +08:00
|
|
|
|
final isBuy = _tradeType == 0;
|
2026-03-28 23:57:51 +08:00
|
|
|
|
final amount = _amountController.text;
|
|
|
|
|
|
final quantity = _calculatedQuantity;
|
|
|
|
|
|
final price = _selectedCoin!.price.toStringAsFixed(2);
|
|
|
|
|
|
final coinCode = _selectedCoin!.code;
|
2026-03-23 00:43:19 +08:00
|
|
|
|
|
2026-03-28 23:57:51 +08:00
|
|
|
|
final confirmed = await showDialog<bool>(
|
2026-03-23 00:43:19 +08:00
|
|
|
|
context: context,
|
2026-04-05 22:38:56 +08:00
|
|
|
|
builder: (ctx) => ConfirmDialog(
|
2026-03-28 23:57:51 +08:00
|
|
|
|
isBuy: isBuy,
|
|
|
|
|
|
coinCode: coinCode,
|
|
|
|
|
|
price: price,
|
|
|
|
|
|
quantity: quantity,
|
|
|
|
|
|
amount: amount,
|
2026-03-23 00:43:19 +08:00
|
|
|
|
),
|
|
|
|
|
|
);
|
2026-03-28 23:57:51 +08:00
|
|
|
|
|
|
|
|
|
|
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();
|
2026-04-07 01:05:05 +08:00
|
|
|
|
// 刷新資產數據
|
2026-03-28 23:57:51 +08:00
|
|
|
|
context.read<AssetProvider>().refreshAll(force: true);
|
2026-04-07 01:05:05 +08:00
|
|
|
|
_showResultDialog(true, '${isBuy ? '買入' : '賣出'}成功',
|
2026-03-28 23:57:51 +08:00
|
|
|
|
'$quantity $coinCode @ $price USDT');
|
|
|
|
|
|
} else {
|
2026-04-07 01:05:05 +08:00
|
|
|
|
_showResultDialog(false, '交易失敗', response.message ?? '請稍後重試');
|
2026-03-28 23:57:51 +08:00
|
|
|
|
}
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
if (mounted) {
|
2026-04-07 01:05:05 +08:00
|
|
|
|
_showResultDialog(false, '交易失敗', e.toString());
|
2026-03-28 23:57:51 +08:00
|
|
|
|
}
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
if (mounted) setState(() => _isSubmitting = false);
|
|
|
|
|
|
}
|
2026-03-23 00:43:19 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-28 23:57:51 +08:00
|
|
|
|
void _showResultDialog(bool success, String title, String message) {
|
2026-04-08 12:24:24 +08:00
|
|
|
|
showDialog(
|
2026-03-23 00:43:19 +08:00
|
|
|
|
context: context,
|
2026-04-08 12:24:24 +08:00
|
|
|
|
builder: (ctx) => AlertDialog(
|
2026-03-23 00:43:19 +08:00
|
|
|
|
title: Row(
|
|
|
|
|
|
children: [
|
2026-03-24 02:16:19 +08:00
|
|
|
|
NeonIcon(
|
2026-03-28 23:57:51 +08:00
|
|
|
|
icon: success ? Icons.check_circle : Icons.error,
|
2026-03-30 03:31:42 +08:00
|
|
|
|
color: success
|
2026-04-06 01:58:08 +08:00
|
|
|
|
? ctx.appColors.up
|
2026-04-08 12:24:24 +08:00
|
|
|
|
: Theme.of(ctx).colorScheme.error,
|
2026-03-24 02:16:19 +08:00
|
|
|
|
size: 24,
|
|
|
|
|
|
),
|
2026-03-23 15:37:59 +08:00
|
|
|
|
SizedBox(width: AppSpacing.sm),
|
2026-03-28 23:57:51 +08:00
|
|
|
|
Text(title),
|
2026-03-23 00:43:19 +08:00
|
|
|
|
],
|
|
|
|
|
|
),
|
2026-04-08 12:24:24 +08:00
|
|
|
|
content: Text(message),
|
2026-03-23 00:43:19 +08:00
|
|
|
|
actions: [
|
2026-04-08 12:24:24 +08:00
|
|
|
|
TextButton(
|
2026-04-07 01:05:05 +08:00
|
|
|
|
child: const Text('確定'),
|
2026-03-28 23:57:51 +08:00
|
|
|
|
onPressed: () => Navigator.of(ctx).pop(),
|
2026-03-23 00:43:19 +08:00
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|