1055 lines
34 KiB
Dart
1055 lines
34 KiB
Dart
import 'dart:math' as math;
|
||
import 'dart:ui' as ui;
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
import 'package:intl/intl.dart';
|
||
import '../../../core/constants/app_colors.dart';
|
||
import '../../../core/utils/format_utils.dart';
|
||
import '../../../data/models/trade_order_model.dart';
|
||
import '../../../domain/entities/price_info.dart';
|
||
import '../../../domain/entities/market_overview.dart';
|
||
import '../../../domain/entities/trade_order.dart';
|
||
import '../../../domain/entities/kline.dart';
|
||
import '../../providers/user_providers.dart';
|
||
import '../../providers/trading_providers.dart';
|
||
import '../../widgets/shimmer_loading.dart';
|
||
|
||
class TradingPage extends ConsumerStatefulWidget {
|
||
const TradingPage({super.key});
|
||
|
||
@override
|
||
ConsumerState<TradingPage> createState() => _TradingPageState();
|
||
}
|
||
|
||
class _TradingPageState extends ConsumerState<TradingPage> {
|
||
// 设计色彩
|
||
static const Color _orange = Color(0xFFFF6B00);
|
||
static const Color _green = Color(0xFF10B981);
|
||
static const Color _red = Color(0xFFEF4444);
|
||
static const Color _grayText = Color(0xFF6B7280);
|
||
static const Color _darkText = Color(0xFF1F2937);
|
||
static const Color _bgGray = Color(0xFFF3F4F6);
|
||
static const Color _lightGray = Color(0xFFF9FAFB);
|
||
static const Color _borderGray = Color(0xFFE5E7EB);
|
||
|
||
// 状态
|
||
int _selectedTab = 1; // 0: 买入, 1: 卖出
|
||
int _selectedTimeRange = 1; // 时间周期选择
|
||
final _quantityController = TextEditingController();
|
||
final _priceController = TextEditingController();
|
||
|
||
final List<String> _timeRanges = ['1分', '5分', '15分', '30分', '1时', '4时', '日'];
|
||
|
||
@override
|
||
void dispose() {
|
||
_quantityController.dispose();
|
||
_priceController.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final priceAsync = ref.watch(currentPriceProvider);
|
||
final marketAsync = ref.watch(marketOverviewProvider);
|
||
final ordersAsync = ref.watch(ordersProvider);
|
||
final user = ref.watch(userNotifierProvider);
|
||
final accountSequence = user.accountSequence ?? '';
|
||
|
||
return Scaffold(
|
||
backgroundColor: const Color(0xFFF5F5F5),
|
||
body: SafeArea(
|
||
bottom: false,
|
||
child: RefreshIndicator(
|
||
onRefresh: () async {
|
||
ref.invalidate(currentPriceProvider);
|
||
ref.invalidate(marketOverviewProvider);
|
||
ref.invalidate(ordersProvider);
|
||
},
|
||
child: Column(
|
||
children: [
|
||
_buildAppBar(),
|
||
Expanded(
|
||
child: SingleChildScrollView(
|
||
child: Column(
|
||
children: [
|
||
_buildPriceCard(priceAsync),
|
||
_buildChartSection(priceAsync),
|
||
_buildMarketDataCard(marketAsync),
|
||
_buildTradingPanel(priceAsync),
|
||
_buildMyOrdersCard(ordersAsync),
|
||
const SizedBox(height: 24),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildAppBar() {
|
||
return Container(
|
||
color: _bgGray.withValues(alpha: 0.9),
|
||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||
child: Row(
|
||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||
children: [
|
||
Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||
decoration: BoxDecoration(
|
||
color: _orange,
|
||
borderRadius: BorderRadius.circular(9999),
|
||
),
|
||
child: const Text(
|
||
'积分股交易',
|
||
style: TextStyle(
|
||
fontSize: 14,
|
||
fontWeight: FontWeight.bold,
|
||
color: Colors.white,
|
||
),
|
||
),
|
||
),
|
||
Container(
|
||
width: 40,
|
||
height: 40,
|
||
decoration: BoxDecoration(
|
||
color: Colors.white,
|
||
borderRadius: BorderRadius.circular(20),
|
||
boxShadow: [
|
||
BoxShadow(
|
||
color: Colors.black.withValues(alpha: 0.1),
|
||
blurRadius: 4,
|
||
offset: const Offset(0, 2),
|
||
),
|
||
],
|
||
),
|
||
child: Stack(
|
||
children: [
|
||
const Center(
|
||
child: Icon(Icons.notifications_outlined, color: _grayText),
|
||
),
|
||
Positioned(
|
||
right: 10,
|
||
top: 10,
|
||
child: Container(
|
||
width: 8,
|
||
height: 8,
|
||
decoration: const BoxDecoration(
|
||
color: Colors.red,
|
||
shape: BoxShape.circle,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildPriceCard(AsyncValue<PriceInfo?> priceAsync) {
|
||
final isLoading = priceAsync.isLoading;
|
||
final priceInfo = priceAsync.valueOrNull;
|
||
final hasError = priceAsync.hasError;
|
||
|
||
if (hasError && priceInfo == null) {
|
||
return _buildErrorCard('价格加载失败');
|
||
}
|
||
|
||
final price = priceInfo?.price ?? '0';
|
||
|
||
return Container(
|
||
margin: const EdgeInsets.all(16),
|
||
padding: const EdgeInsets.all(20),
|
||
decoration: BoxDecoration(
|
||
color: Colors.white,
|
||
borderRadius: BorderRadius.circular(16),
|
||
),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
const Text(
|
||
'当前积分股价格',
|
||
style: TextStyle(
|
||
fontSize: 12,
|
||
fontWeight: FontWeight.w500,
|
||
color: _grayText,
|
||
),
|
||
),
|
||
const SizedBox(height: 8),
|
||
Row(
|
||
crossAxisAlignment: CrossAxisAlignment.center,
|
||
children: [
|
||
AmountText(
|
||
amount: priceInfo != null ? formatPrice(price) : null,
|
||
isLoading: isLoading,
|
||
prefix: '\u00A5 ',
|
||
style: const TextStyle(
|
||
fontSize: 30,
|
||
fontWeight: FontWeight.bold,
|
||
color: _orange,
|
||
letterSpacing: -0.75,
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||
decoration: BoxDecoration(
|
||
color: _green.withValues(alpha: 0.1),
|
||
borderRadius: BorderRadius.circular(16),
|
||
),
|
||
child: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
const Icon(Icons.trending_up, size: 16, color: _green),
|
||
DataText(
|
||
data: isLoading ? null : '+0.00%',
|
||
isLoading: isLoading,
|
||
placeholder: '+--.--%',
|
||
style: const TextStyle(
|
||
fontSize: 14,
|
||
fontWeight: FontWeight.bold,
|
||
color: _green,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildChartSection(AsyncValue<PriceInfo?> priceAsync) {
|
||
final priceInfo = priceAsync.valueOrNull;
|
||
final currentPrice = priceInfo?.price ?? '0.000000';
|
||
final klinesAsync = ref.watch(klinesProvider);
|
||
final klines = klinesAsync.valueOrNull ?? [];
|
||
|
||
return Container(
|
||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
||
padding: const EdgeInsets.all(16),
|
||
decoration: BoxDecoration(
|
||
color: Colors.white,
|
||
borderRadius: BorderRadius.circular(16),
|
||
),
|
||
child: Column(
|
||
children: [
|
||
Container(
|
||
height: 200,
|
||
decoration: BoxDecoration(
|
||
color: _lightGray,
|
||
borderRadius: BorderRadius.circular(8),
|
||
),
|
||
child: klinesAsync.isLoading
|
||
? const Center(child: CircularProgressIndicator(strokeWidth: 2))
|
||
: Stack(
|
||
children: [
|
||
CustomPaint(
|
||
size: const Size(double.infinity, 200),
|
||
painter: _CandlestickPainter(klines: klines),
|
||
),
|
||
if (klines.isNotEmpty)
|
||
Positioned(
|
||
right: 0,
|
||
top: 60,
|
||
child: Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
|
||
decoration: BoxDecoration(
|
||
color: _orange,
|
||
borderRadius: BorderRadius.circular(4),
|
||
boxShadow: [
|
||
BoxShadow(
|
||
color: Colors.black.withValues(alpha: 0.1),
|
||
blurRadius: 2,
|
||
offset: const Offset(0, 1),
|
||
),
|
||
],
|
||
),
|
||
child: Text(
|
||
formatPrice(currentPrice),
|
||
style: const TextStyle(fontSize: 10, color: Colors.white),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(height: 16),
|
||
SingleChildScrollView(
|
||
scrollDirection: Axis.horizontal,
|
||
child: Row(
|
||
children: List.generate(_timeRanges.length, (index) {
|
||
final isSelected = _selectedTimeRange == index;
|
||
return Padding(
|
||
padding: const EdgeInsets.only(right: 8),
|
||
child: GestureDetector(
|
||
onTap: () {
|
||
setState(() => _selectedTimeRange = index);
|
||
// 更新选中的周期,触发K线数据刷新
|
||
ref.read(selectedKlinePeriodProvider.notifier).state = _timeRanges[index];
|
||
},
|
||
child: Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
||
decoration: BoxDecoration(
|
||
color: isSelected ? _orange : Colors.white,
|
||
borderRadius: BorderRadius.circular(9999),
|
||
border: isSelected ? null : Border.all(color: _borderGray),
|
||
),
|
||
child: Text(
|
||
_timeRanges[index],
|
||
style: TextStyle(
|
||
fontSize: 12,
|
||
fontWeight: FontWeight.w500,
|
||
color: isSelected ? Colors.white : _grayText,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildMarketDataCard(AsyncValue<MarketOverview?> marketAsync) {
|
||
final isLoading = marketAsync.isLoading;
|
||
final market = marketAsync.valueOrNull;
|
||
final hasError = marketAsync.hasError;
|
||
|
||
if (hasError && market == null) {
|
||
return _buildErrorCard('市场数据加载失败');
|
||
}
|
||
|
||
return Container(
|
||
margin: const EdgeInsets.all(16),
|
||
padding: const EdgeInsets.all(20),
|
||
decoration: BoxDecoration(
|
||
color: Colors.white,
|
||
borderRadius: BorderRadius.circular(16),
|
||
),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
Container(
|
||
width: 4,
|
||
height: 16,
|
||
decoration: BoxDecoration(
|
||
color: _orange,
|
||
borderRadius: BorderRadius.circular(2),
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
const Text(
|
||
'市场数据',
|
||
style: TextStyle(
|
||
fontSize: 14,
|
||
fontWeight: FontWeight.bold,
|
||
color: _darkText,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 24),
|
||
Row(
|
||
children: [
|
||
_buildMarketDataItem(
|
||
'总积分股',
|
||
market != null ? formatCompact(market.totalShares) : null,
|
||
_orange,
|
||
isLoading,
|
||
),
|
||
Container(width: 1, height: 24, color: _bgGray),
|
||
const SizedBox(width: 16),
|
||
_buildMarketDataItem(
|
||
'流通池',
|
||
market != null ? formatCompact(market.circulationPool) : null,
|
||
_orange,
|
||
isLoading,
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 24),
|
||
Container(height: 1, color: _bgGray),
|
||
const SizedBox(height: 24),
|
||
Row(
|
||
children: [
|
||
_buildMarketDataItem(
|
||
'积分值池',
|
||
market != null ? formatCompact(market.greenPoints) : null,
|
||
_orange,
|
||
isLoading,
|
||
),
|
||
Container(width: 1, height: 24, color: _bgGray),
|
||
const SizedBox(width: 16),
|
||
_buildMarketDataItem(
|
||
'黑洞销毁量',
|
||
market != null ? formatCompact(market.blackHoleAmount) : null,
|
||
_red,
|
||
isLoading,
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildMarketDataItem(String label, String? value, Color valueColor, bool isLoading) {
|
||
return Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(label, style: const TextStyle(fontSize: 12, color: _grayText)),
|
||
const SizedBox(height: 4),
|
||
DataText(
|
||
data: value,
|
||
isLoading: isLoading,
|
||
placeholder: '--,---,---',
|
||
style: TextStyle(
|
||
fontSize: 14,
|
||
fontWeight: FontWeight.bold,
|
||
color: valueColor,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildTradingPanel(AsyncValue<PriceInfo?> priceAsync) {
|
||
final priceInfo = priceAsync.valueOrNull;
|
||
final currentPrice = priceInfo?.price ?? '0';
|
||
|
||
// 设置默认价格
|
||
if (_priceController.text.isEmpty && priceInfo != null) {
|
||
_priceController.text = currentPrice;
|
||
}
|
||
|
||
return Container(
|
||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
||
padding: const EdgeInsets.all(20),
|
||
decoration: BoxDecoration(
|
||
color: Colors.white,
|
||
borderRadius: BorderRadius.circular(16),
|
||
),
|
||
child: Column(
|
||
children: [
|
||
Container(
|
||
decoration: const BoxDecoration(
|
||
border: Border(bottom: BorderSide(color: _bgGray)),
|
||
),
|
||
child: Row(
|
||
children: [
|
||
Expanded(
|
||
child: GestureDetector(
|
||
onTap: () => setState(() => _selectedTab = 0),
|
||
child: Container(
|
||
padding: const EdgeInsets.only(bottom: 12),
|
||
decoration: BoxDecoration(
|
||
border: Border(
|
||
bottom: BorderSide(
|
||
color: _selectedTab == 0 ? _orange : Colors.transparent,
|
||
width: 2,
|
||
),
|
||
),
|
||
),
|
||
child: Text(
|
||
'买入',
|
||
textAlign: TextAlign.center,
|
||
style: TextStyle(
|
||
fontSize: 14,
|
||
fontWeight: FontWeight.bold,
|
||
color: _selectedTab == 0 ? _orange : _grayText,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
Expanded(
|
||
child: GestureDetector(
|
||
onTap: () => setState(() => _selectedTab = 1),
|
||
child: Container(
|
||
padding: const EdgeInsets.only(bottom: 12),
|
||
decoration: BoxDecoration(
|
||
border: Border(
|
||
bottom: BorderSide(
|
||
color: _selectedTab == 1 ? _orange : Colors.transparent,
|
||
width: 2,
|
||
),
|
||
),
|
||
),
|
||
child: Text(
|
||
'卖出',
|
||
textAlign: TextAlign.center,
|
||
style: TextStyle(
|
||
fontSize: 14,
|
||
fontWeight: FontWeight.bold,
|
||
color: _selectedTab == 1 ? _orange : _grayText,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(height: 24),
|
||
// 价格输入
|
||
_buildInputField('价格', _priceController, '请输入价格', '积分值'),
|
||
const SizedBox(height: 16),
|
||
// 数量输入
|
||
_buildInputField('数量', _quantityController, '请输入数量', '积分股'),
|
||
const SizedBox(height: 16),
|
||
// 预计获得/支出
|
||
Container(
|
||
padding: const EdgeInsets.all(12),
|
||
decoration: BoxDecoration(
|
||
color: _bgGray,
|
||
borderRadius: BorderRadius.circular(12),
|
||
),
|
||
child: Row(
|
||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||
children: [
|
||
Text(
|
||
_selectedTab == 0 ? '预计支出' : '预计获得',
|
||
style: const TextStyle(fontSize: 12, color: _grayText),
|
||
),
|
||
Text(
|
||
_calculateEstimate(),
|
||
style: const TextStyle(
|
||
fontSize: 14,
|
||
fontWeight: FontWeight.bold,
|
||
color: _orange,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(height: 16),
|
||
// 销毁说明 (卖出时显示)
|
||
if (_selectedTab == 1)
|
||
Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||
child: Row(
|
||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||
children: const [
|
||
Text(
|
||
'销毁比例',
|
||
style: TextStyle(fontSize: 12, color: _grayText),
|
||
),
|
||
Text(
|
||
'10% 进入黑洞',
|
||
style: TextStyle(
|
||
fontSize: 12,
|
||
color: _red,
|
||
fontFamily: 'monospace',
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(height: 24),
|
||
// 提交按钮
|
||
SizedBox(
|
||
width: double.infinity,
|
||
height: 48,
|
||
child: ElevatedButton(
|
||
onPressed: _handleTrade,
|
||
style: ElevatedButton.styleFrom(
|
||
backgroundColor: _orange,
|
||
shape: RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.circular(12),
|
||
),
|
||
),
|
||
child: Text(
|
||
_selectedTab == 0 ? '买入积分股' : '卖出积分股',
|
||
style: const TextStyle(
|
||
fontSize: 16,
|
||
fontWeight: FontWeight.bold,
|
||
color: Colors.white,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildInputField(
|
||
String label,
|
||
TextEditingController controller,
|
||
String hint,
|
||
String suffix,
|
||
) {
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
label,
|
||
style: const TextStyle(
|
||
fontSize: 12,
|
||
fontWeight: FontWeight.w500,
|
||
color: _grayText,
|
||
),
|
||
),
|
||
const SizedBox(height: 8),
|
||
Container(
|
||
height: 44,
|
||
decoration: BoxDecoration(
|
||
color: _bgGray,
|
||
borderRadius: BorderRadius.circular(12),
|
||
),
|
||
child: Row(
|
||
children: [
|
||
Expanded(
|
||
child: TextField(
|
||
controller: controller,
|
||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||
decoration: InputDecoration(
|
||
hintText: hint,
|
||
hintStyle: const TextStyle(
|
||
fontSize: 14,
|
||
color: Color(0xFF9CA3AF),
|
||
),
|
||
border: InputBorder.none,
|
||
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
|
||
),
|
||
),
|
||
),
|
||
Text(suffix, style: const TextStyle(fontSize: 12, color: _grayText)),
|
||
const SizedBox(width: 12),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
String _calculateEstimate() {
|
||
final price = double.tryParse(_priceController.text) ?? 0;
|
||
final quantity = double.tryParse(_quantityController.text) ?? 0;
|
||
final total = price * quantity;
|
||
|
||
if (total == 0) {
|
||
return '0.00 积分值';
|
||
}
|
||
|
||
if (_selectedTab == 1) {
|
||
// 卖出时扣除10%销毁
|
||
final afterBurn = total * 0.9;
|
||
return '${formatAmount(afterBurn.toString())} 积分值';
|
||
}
|
||
|
||
return '${formatAmount(total.toString())} 积分值';
|
||
}
|
||
|
||
Widget _buildMyOrdersCard(AsyncValue<OrdersPageModel?> ordersAsync) {
|
||
final isLoading = ordersAsync.isLoading;
|
||
final ordersPage = ordersAsync.valueOrNull;
|
||
final orders = ordersPage?.data ?? [];
|
||
|
||
return Container(
|
||
margin: const EdgeInsets.all(16),
|
||
padding: const EdgeInsets.all(20),
|
||
decoration: BoxDecoration(
|
||
color: Colors.white,
|
||
borderRadius: BorderRadius.circular(16),
|
||
),
|
||
child: Column(
|
||
children: [
|
||
Row(
|
||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||
children: [
|
||
const Text(
|
||
'我的挂单',
|
||
style: TextStyle(
|
||
fontSize: 14,
|
||
fontWeight: FontWeight.bold,
|
||
color: _darkText,
|
||
),
|
||
),
|
||
GestureDetector(
|
||
onTap: () {
|
||
// TODO: 查看全部挂单
|
||
},
|
||
child: const Text(
|
||
'全部 >',
|
||
style: TextStyle(
|
||
fontSize: 12,
|
||
fontWeight: FontWeight.w500,
|
||
color: _orange,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 16),
|
||
if (isLoading)
|
||
const Center(
|
||
child: Padding(
|
||
padding: EdgeInsets.all(20),
|
||
child: CircularProgressIndicator(color: _orange),
|
||
),
|
||
)
|
||
else if (orders.isEmpty)
|
||
const Padding(
|
||
padding: EdgeInsets.symmetric(vertical: 20),
|
||
child: Text(
|
||
'暂无挂单',
|
||
style: TextStyle(fontSize: 14, color: _grayText),
|
||
),
|
||
)
|
||
else
|
||
Column(
|
||
children: orders.take(3).map((order) => Padding(
|
||
padding: const EdgeInsets.only(bottom: 8),
|
||
child: _buildOrderItemFromEntity(order),
|
||
)).toList(),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildOrderItemFromEntity(TradeOrder order) {
|
||
final isSell = order.isSell;
|
||
final dateFormat = DateFormat('MM/dd HH:mm');
|
||
final formattedDate = dateFormat.format(order.createdAt);
|
||
|
||
String statusText;
|
||
switch (order.status) {
|
||
case OrderStatus.pending:
|
||
statusText = '待成交';
|
||
break;
|
||
case OrderStatus.partial:
|
||
statusText = '部分成交';
|
||
break;
|
||
case OrderStatus.filled:
|
||
statusText = '已成交';
|
||
break;
|
||
case OrderStatus.cancelled:
|
||
statusText = '已取消';
|
||
break;
|
||
}
|
||
|
||
return Container(
|
||
padding: const EdgeInsets.all(12),
|
||
decoration: BoxDecoration(
|
||
color: _lightGray,
|
||
borderRadius: BorderRadius.circular(8),
|
||
border: Border.all(color: _bgGray),
|
||
),
|
||
child: Row(
|
||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||
children: [
|
||
Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||
decoration: BoxDecoration(
|
||
color: (isSell ? _red : _green).withValues(alpha: 0.1),
|
||
borderRadius: BorderRadius.circular(6),
|
||
),
|
||
child: Text(
|
||
isSell ? '卖出' : '买入',
|
||
style: TextStyle(
|
||
fontSize: 12,
|
||
fontWeight: FontWeight.bold,
|
||
color: isSell ? _red : _green,
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
Text(
|
||
formatPrice(order.price),
|
||
style: const TextStyle(
|
||
fontSize: 14,
|
||
fontWeight: FontWeight.bold,
|
||
color: _darkText,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 4),
|
||
Text(
|
||
formattedDate,
|
||
style: const TextStyle(fontSize: 12, color: _grayText),
|
||
),
|
||
],
|
||
),
|
||
Column(
|
||
crossAxisAlignment: CrossAxisAlignment.end,
|
||
children: [
|
||
Text(
|
||
'${formatCompact(order.quantity)} 股',
|
||
style: const TextStyle(
|
||
fontSize: 14,
|
||
fontWeight: FontWeight.w500,
|
||
color: _darkText,
|
||
),
|
||
),
|
||
const SizedBox(height: 4),
|
||
Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||
decoration: BoxDecoration(
|
||
color: _orange.withValues(alpha: 0.1),
|
||
borderRadius: BorderRadius.circular(9999),
|
||
),
|
||
child: Text(
|
||
statusText,
|
||
style: const TextStyle(fontSize: 12, color: _orange),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildErrorCard(String message) {
|
||
return Container(
|
||
margin: const EdgeInsets.all(16),
|
||
padding: const EdgeInsets.all(32),
|
||
decoration: BoxDecoration(
|
||
color: Colors.white,
|
||
borderRadius: BorderRadius.circular(16),
|
||
),
|
||
child: Center(
|
||
child: Column(
|
||
children: [
|
||
const Icon(Icons.error_outline, size: 48, color: AppColors.error),
|
||
const SizedBox(height: 8),
|
||
Text(message),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
void _handleTrade() async {
|
||
if (_priceController.text.isEmpty) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
const SnackBar(content: Text('请输入价格')),
|
||
);
|
||
return;
|
||
}
|
||
|
||
if (_quantityController.text.isEmpty) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
const SnackBar(content: Text('请输入数量')),
|
||
);
|
||
return;
|
||
}
|
||
|
||
final isBuy = _selectedTab == 0;
|
||
bool success;
|
||
|
||
if (isBuy) {
|
||
success = await ref
|
||
.read(tradingNotifierProvider.notifier)
|
||
.buyShares(_priceController.text, _quantityController.text);
|
||
} else {
|
||
success = await ref
|
||
.read(tradingNotifierProvider.notifier)
|
||
.sellShares(_priceController.text, _quantityController.text);
|
||
}
|
||
|
||
if (mounted) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(
|
||
content: Text(success
|
||
? (isBuy ? '买入订单已提交' : '卖出订单已提交')
|
||
: (isBuy ? '买入失败' : '卖出失败')),
|
||
backgroundColor: success ? _green : AppColors.error,
|
||
),
|
||
);
|
||
if (success) {
|
||
_quantityController.clear();
|
||
// 交易成功后刷新订单列表
|
||
ref.invalidate(ordersProvider);
|
||
ref.invalidate(currentPriceProvider);
|
||
ref.invalidate(marketOverviewProvider);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// K线图绘制器(Y轴自适应,显示真实数据)
|
||
class _CandlestickPainter extends CustomPainter {
|
||
final List<Kline> klines;
|
||
|
||
_CandlestickPainter({required this.klines});
|
||
|
||
@override
|
||
void paint(Canvas canvas, Size size) {
|
||
final greenPaint = Paint()..color = const Color(0xFF10B981);
|
||
final redPaint = Paint()..color = const Color(0xFFEF4444);
|
||
final gridPaint = Paint()
|
||
..color = const Color(0xFFE5E7EB)
|
||
..strokeWidth = 0.5;
|
||
final textPaint = TextPainter(textDirection: ui.TextDirection.ltr);
|
||
|
||
// 如果没有数据,显示提示
|
||
if (klines.isEmpty) {
|
||
textPaint.text = const TextSpan(
|
||
text: '暂无K线数据',
|
||
style: TextStyle(color: Color(0xFF6B7280), fontSize: 14),
|
||
);
|
||
textPaint.layout();
|
||
textPaint.paint(
|
||
canvas,
|
||
Offset((size.width - textPaint.width) / 2, (size.height - textPaint.height) / 2),
|
||
);
|
||
return;
|
||
}
|
||
|
||
// 计算Y轴范围(自适应)
|
||
double minPrice = double.infinity;
|
||
double maxPrice = double.negativeInfinity;
|
||
for (final kline in klines) {
|
||
final low = double.tryParse(kline.low) ?? 0;
|
||
final high = double.tryParse(kline.high) ?? 0;
|
||
if (low < minPrice) minPrice = low;
|
||
if (high > maxPrice) maxPrice = high;
|
||
}
|
||
|
||
// 添加一点余量,使K线不贴边
|
||
final priceRange = maxPrice - minPrice;
|
||
final padding = priceRange * 0.1; // 上下各留10%空间
|
||
minPrice -= padding;
|
||
maxPrice += padding;
|
||
final adjustedRange = maxPrice - minPrice;
|
||
|
||
// 绘图区域
|
||
const leftPadding = 10.0;
|
||
const rightPadding = 50.0; // 右侧留出价格标签空间
|
||
const topPadding = 10.0;
|
||
const bottomPadding = 10.0;
|
||
final chartWidth = size.width - leftPadding - rightPadding;
|
||
final chartHeight = size.height - topPadding - bottomPadding;
|
||
|
||
// 绘制水平网格线和价格标签
|
||
const gridLines = 4;
|
||
for (int i = 0; i <= gridLines; i++) {
|
||
final y = topPadding + (chartHeight / gridLines) * i;
|
||
canvas.drawLine(
|
||
Offset(leftPadding, y),
|
||
Offset(size.width - rightPadding, y),
|
||
gridPaint,
|
||
);
|
||
|
||
// 价格标签
|
||
final price = maxPrice - (adjustedRange / gridLines) * i;
|
||
final priceText = _formatPriceLabel(price);
|
||
textPaint.text = TextSpan(
|
||
text: priceText,
|
||
style: const TextStyle(color: Color(0xFF6B7280), fontSize: 9),
|
||
);
|
||
textPaint.layout();
|
||
textPaint.paint(canvas, Offset(size.width - rightPadding + 4, y - textPaint.height / 2));
|
||
}
|
||
|
||
// 计算K线宽度
|
||
final candleWidth = chartWidth / klines.length;
|
||
final bodyWidth = math.max(candleWidth * 0.6, 2.0); // 实体宽度,最小2px
|
||
|
||
// 绘制K线
|
||
for (int i = 0; i < klines.length; i++) {
|
||
final kline = klines[i];
|
||
final open = double.tryParse(kline.open) ?? 0;
|
||
final close = double.tryParse(kline.close) ?? 0;
|
||
final high = double.tryParse(kline.high) ?? 0;
|
||
final low = double.tryParse(kline.low) ?? 0;
|
||
|
||
final isGreen = close >= open;
|
||
final paint = isGreen ? greenPaint : redPaint;
|
||
|
||
final x = leftPadding + i * candleWidth + candleWidth / 2;
|
||
|
||
// Y坐标转换(价格 -> 屏幕坐标)
|
||
double priceToY(double price) {
|
||
return topPadding + ((maxPrice - price) / adjustedRange) * chartHeight;
|
||
}
|
||
|
||
final yOpen = priceToY(open);
|
||
final yClose = priceToY(close);
|
||
final yHigh = priceToY(high);
|
||
final yLow = priceToY(low);
|
||
|
||
// 绘制影线
|
||
canvas.drawLine(
|
||
Offset(x, yHigh),
|
||
Offset(x, yLow),
|
||
paint..strokeWidth = 1,
|
||
);
|
||
|
||
// 绘制实体
|
||
final bodyTop = math.min(yOpen, yClose);
|
||
final bodyBottom = math.max(yOpen, yClose);
|
||
// 确保实体至少有1px高度
|
||
final minBodyHeight = 1.0;
|
||
final actualBodyBottom = bodyBottom - bodyTop < minBodyHeight ? bodyTop + minBodyHeight : bodyBottom;
|
||
|
||
canvas.drawRect(
|
||
Rect.fromLTRB(x - bodyWidth / 2, bodyTop, x + bodyWidth / 2, actualBodyBottom),
|
||
paint..style = PaintingStyle.fill,
|
||
);
|
||
}
|
||
|
||
// 绘制最新价格虚线
|
||
if (klines.isNotEmpty) {
|
||
final lastClose = double.tryParse(klines.last.close) ?? 0;
|
||
final lastY = topPadding + ((maxPrice - lastClose) / adjustedRange) * chartHeight;
|
||
|
||
final dashPaint = Paint()
|
||
..color = const Color(0xFFFF6B00)
|
||
..strokeWidth = 1
|
||
..style = PaintingStyle.stroke;
|
||
|
||
const dashWidth = 5.0;
|
||
const dashSpace = 3.0;
|
||
double startX = leftPadding;
|
||
while (startX < size.width - rightPadding) {
|
||
canvas.drawLine(
|
||
Offset(startX, lastY),
|
||
Offset(math.min(startX + dashWidth, size.width - rightPadding), lastY),
|
||
dashPaint,
|
||
);
|
||
startX += dashWidth + dashSpace;
|
||
}
|
||
}
|
||
}
|
||
|
||
// 格式化价格标签
|
||
String _formatPriceLabel(double price) {
|
||
if (price >= 1) {
|
||
return price.toStringAsFixed(4);
|
||
} else if (price >= 0.0001) {
|
||
return price.toStringAsFixed(6);
|
||
} else {
|
||
return price.toStringAsExponential(2);
|
||
}
|
||
}
|
||
|
||
@override
|
||
bool shouldRepaint(covariant _CandlestickPainter oldDelegate) {
|
||
return oldDelegate.klines != klines;
|
||
}
|
||
}
|