feat(mining-app): add professional kline chart with technical indicators

- Add KlineChartWidget with pinch-to-zoom, fullscreen mode
- Implement MA, EMA, BOLL indicators for main chart
- Implement MACD, KDJ, RSI indicators for sub chart
- Add volume display with crossline info
- Add C2C trading feature with market/publish/detail pages
- Add P2P transfer functionality (send/receive shares)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-01-16 08:51:00 -08:00
parent 3ce8bb0044
commit 20a90fce4c
21 changed files with 6137 additions and 277 deletions

View File

@ -45,11 +45,26 @@ class ApiEndpoints {
static const String estimateSell = '/api/v2/trading/asset/estimate-sell';
static const String marketOverview = '/api/v2/trading/asset/market';
// Transfer endpoints
// Transfer endpoints ()
static const String transferIn = '/api/v2/trading/transfers/in';
static const String transferOut = '/api/v2/trading/transfers/out';
static const String transferHistory = '/api/v2/trading/transfers/history';
// P2P Transfer endpoints ()
static const String p2pTransfer = '/api/v2/trading/p2p/transfer';
static String p2pTransferHistory(String accountSequence) =>
'/api/v2/trading/p2p/transfers/$accountSequence';
static const String lookupAccount = '/api/v2/auth/user/lookup';
// C2C Trading endpoints ()
static const String c2cOrders = '/api/v2/trading/c2c/orders';
static const String c2cMyOrders = '/api/v2/trading/c2c/orders/my';
static String c2cOrderDetail(String orderNo) => '/api/v2/trading/c2c/orders/$orderNo';
static String c2cTakeOrder(String orderNo) => '/api/v2/trading/c2c/orders/$orderNo/take';
static String c2cCancelOrder(String orderNo) => '/api/v2/trading/c2c/orders/$orderNo/cancel';
static String c2cConfirmPayment(String orderNo) => '/api/v2/trading/c2c/orders/$orderNo/confirm-payment';
static String c2cConfirmReceived(String orderNo) => '/api/v2/trading/c2c/orders/$orderNo/confirm-received';
// Contribution Service 2.0 (Kong路由: /api/v2/contribution -> /api/v1/contributions)
static const String contributionStats = '/api/v2/contribution/stats';
static String contribution(String accountSequence) =>

View File

@ -14,6 +14,11 @@ import '../../presentation/pages/profile/profile_page.dart';
import '../../presentation/pages/profile/edit_profile_page.dart';
import '../../presentation/pages/profile/mining_records_page.dart';
import '../../presentation/pages/profile/planting_records_page.dart';
import '../../presentation/pages/asset/send_shares_page.dart';
import '../../presentation/pages/asset/receive_shares_page.dart';
import '../../presentation/pages/c2c/c2c_market_page.dart';
import '../../presentation/pages/c2c/c2c_publish_page.dart';
import '../../presentation/pages/c2c/c2c_order_detail_page.dart';
import '../../presentation/widgets/main_shell.dart';
import '../../presentation/providers/user_providers.dart';
import 'routes.dart';
@ -117,6 +122,29 @@ final appRouterProvider = Provider<GoRouter>((ref) {
path: Routes.editProfile,
builder: (context, state) => const EditProfilePage(),
),
GoRoute(
path: Routes.sendShares,
builder: (context, state) => const SendSharesPage(),
),
GoRoute(
path: Routes.receiveShares,
builder: (context, state) => const ReceiveSharesPage(),
),
GoRoute(
path: Routes.c2cMarket,
builder: (context, state) => const C2cMarketPage(),
),
GoRoute(
path: Routes.c2cPublish,
builder: (context, state) => const C2cPublishPage(),
),
GoRoute(
path: Routes.c2cOrderDetail,
builder: (context, state) {
final orderNo = state.extra as String;
return C2cOrderDetailPage(orderNo: orderNo);
},
),
ShellRoute(
builder: (context, state, child) => MainShell(child: child),
routes: [

View File

@ -13,4 +13,10 @@ class Routes {
static const String contributionRecords = '/contribution-records';
static const String plantingRecords = '/planting-records';
static const String orders = '/orders';
static const String sendShares = '/send-shares';
static const String receiveShares = '/receive-shares';
// C2C交易路由
static const String c2cMarket = '/c2c-market';
static const String c2cPublish = '/c2c-publish';
static const String c2cOrderDetail = '/c2c-order-detail';
}

View File

@ -4,6 +4,8 @@ import '../../models/trading_account_model.dart';
import '../../models/market_overview_model.dart';
import '../../models/asset_display_model.dart';
import '../../models/kline_model.dart';
import '../../models/p2p_transfer_model.dart';
import '../../models/c2c_order_model.dart';
import '../../../core/network/api_client.dart';
import '../../../core/network/api_endpoints.dart';
import '../../../core/error/exceptions.dart';
@ -51,6 +53,60 @@ abstract class TradingRemoteDataSource {
/// K线数据
Future<List<KlineModel>> getKlines({String period = '1h', int limit = 100});
/// P2P转账 -
Future<P2pTransferModel> p2pTransfer({
required String toPhone,
required String amount,
String? memo,
});
///
Future<AccountLookupModel> lookupAccount(String phone);
/// P2P转账历史
Future<List<P2pTransferModel>> getP2pTransferHistory(String accountSequence);
// ============ C2C交易接口 ============
/// C2C订单列表广
Future<C2cOrdersPageModel> getC2cOrders({
String? type, // BUY/SELL
int page = 1,
int pageSize = 20,
});
/// C2C订单
Future<C2cOrdersPageModel> getMyC2cOrders({
String? status,
int page = 1,
int pageSize = 20,
});
/// C2C订单广
Future<C2cOrderModel> createC2cOrder({
required String type, // BUY/SELL
required String price,
required String quantity,
String? minAmount,
String? maxAmount,
String? remark,
});
/// C2C订单详情
Future<C2cOrderModel> getC2cOrderDetail(String orderNo);
///
Future<C2cOrderModel> takeC2cOrder(String orderNo, {String? quantity});
/// C2C订单
Future<void> cancelC2cOrder(String orderNo);
///
Future<C2cOrderModel> confirmC2cPayment(String orderNo);
///
Future<C2cOrderModel> confirmC2cReceived(String orderNo);
}
class TradingRemoteDataSourceImpl implements TradingRemoteDataSource {
@ -220,4 +276,194 @@ class TradingRemoteDataSourceImpl implements TradingRemoteDataSource {
throw ServerException(e.toString());
}
}
@override
Future<P2pTransferModel> p2pTransfer({
required String toPhone,
required String amount,
String? memo,
}) async {
try {
final data = <String, dynamic>{
'toPhone': toPhone,
'amount': amount,
};
if (memo != null && memo.isNotEmpty) {
data['memo'] = memo;
}
final response = await client.post(
ApiEndpoints.p2pTransfer,
data: data,
);
return P2pTransferModel.fromJson(response.data);
} catch (e) {
throw ServerException(e.toString());
}
}
@override
Future<AccountLookupModel> lookupAccount(String phone) async {
try {
final response = await client.get(
ApiEndpoints.lookupAccount,
queryParameters: {'phone': phone},
);
return AccountLookupModel.fromJson(response.data);
} catch (e) {
throw ServerException(e.toString());
}
}
@override
Future<List<P2pTransferModel>> getP2pTransferHistory(String accountSequence) async {
try {
final response = await client.get(
ApiEndpoints.p2pTransferHistory(accountSequence),
);
final List<dynamic> data = response.data;
return data.map((json) => P2pTransferModel.fromJson(json)).toList();
} catch (e) {
throw ServerException(e.toString());
}
}
// ============ C2C交易实现 ============
@override
Future<C2cOrdersPageModel> getC2cOrders({
String? type,
int page = 1,
int pageSize = 20,
}) async {
try {
final queryParams = <String, dynamic>{
'page': page,
'pageSize': pageSize,
};
if (type != null) {
queryParams['type'] = type;
}
final response = await client.get(
ApiEndpoints.c2cOrders,
queryParameters: queryParams,
);
return C2cOrdersPageModel.fromJson(response.data);
} catch (e) {
throw ServerException(e.toString());
}
}
@override
Future<C2cOrdersPageModel> getMyC2cOrders({
String? status,
int page = 1,
int pageSize = 20,
}) async {
try {
final queryParams = <String, dynamic>{
'page': page,
'pageSize': pageSize,
};
if (status != null) {
queryParams['status'] = status;
}
final response = await client.get(
ApiEndpoints.c2cMyOrders,
queryParameters: queryParams,
);
return C2cOrdersPageModel.fromJson(response.data);
} catch (e) {
throw ServerException(e.toString());
}
}
@override
Future<C2cOrderModel> createC2cOrder({
required String type,
required String price,
required String quantity,
String? minAmount,
String? maxAmount,
String? remark,
}) async {
try {
final data = <String, dynamic>{
'type': type,
'price': price,
'quantity': quantity,
};
if (minAmount != null) data['minAmount'] = minAmount;
if (maxAmount != null) data['maxAmount'] = maxAmount;
if (remark != null && remark.isNotEmpty) data['remark'] = remark;
final response = await client.post(
ApiEndpoints.c2cOrders,
data: data,
);
return C2cOrderModel.fromJson(response.data);
} catch (e) {
throw ServerException(e.toString());
}
}
@override
Future<C2cOrderModel> getC2cOrderDetail(String orderNo) async {
try {
final response = await client.get(
ApiEndpoints.c2cOrderDetail(orderNo),
);
return C2cOrderModel.fromJson(response.data);
} catch (e) {
throw ServerException(e.toString());
}
}
@override
Future<C2cOrderModel> takeC2cOrder(String orderNo, {String? quantity}) async {
try {
final data = <String, dynamic>{};
if (quantity != null) data['quantity'] = quantity;
final response = await client.post(
ApiEndpoints.c2cTakeOrder(orderNo),
data: data.isNotEmpty ? data : null,
);
return C2cOrderModel.fromJson(response.data);
} catch (e) {
throw ServerException(e.toString());
}
}
@override
Future<void> cancelC2cOrder(String orderNo) async {
try {
await client.post(ApiEndpoints.c2cCancelOrder(orderNo));
} catch (e) {
throw ServerException(e.toString());
}
}
@override
Future<C2cOrderModel> confirmC2cPayment(String orderNo) async {
try {
final response = await client.post(
ApiEndpoints.c2cConfirmPayment(orderNo),
);
return C2cOrderModel.fromJson(response.data);
} catch (e) {
throw ServerException(e.toString());
}
}
@override
Future<C2cOrderModel> confirmC2cReceived(String orderNo) async {
try {
final response = await client.post(
ApiEndpoints.c2cConfirmReceived(orderNo),
);
return C2cOrderModel.fromJson(response.data);
} catch (e) {
throw ServerException(e.toString());
}
}
}

View File

@ -0,0 +1,179 @@
/// C2C订单类型
enum C2cOrderType {
buy, //
sell, //
}
/// C2C订单状态
enum C2cOrderStatus {
pending, //
matched, //
paid, //
completed, //
cancelled, //
expired, //
}
/// C2C订单模型
class C2cOrderModel {
final String orderNo;
final C2cOrderType type;
final String makerAccountSequence;
final String? makerPhone;
final String? makerNickname;
final String? takerAccountSequence;
final String? takerPhone;
final String? takerNickname;
final String price; //
final String quantity; //
final String totalAmount; //
final String minAmount; //
final String maxAmount; //
final C2cOrderStatus status;
final String? remark;
final DateTime createdAt;
final DateTime? matchedAt;
final DateTime? paidAt;
final DateTime? completedAt;
final DateTime? expiredAt;
C2cOrderModel({
required this.orderNo,
required this.type,
required this.makerAccountSequence,
this.makerPhone,
this.makerNickname,
this.takerAccountSequence,
this.takerPhone,
this.takerNickname,
required this.price,
required this.quantity,
required this.totalAmount,
required this.minAmount,
required this.maxAmount,
required this.status,
this.remark,
required this.createdAt,
this.matchedAt,
this.paidAt,
this.completedAt,
this.expiredAt,
});
factory C2cOrderModel.fromJson(Map<String, dynamic> json) {
return C2cOrderModel(
orderNo: json['orderNo'] ?? '',
type: _parseOrderType(json['type']),
makerAccountSequence: json['makerAccountSequence'] ?? '',
makerPhone: json['makerPhone'],
makerNickname: json['makerNickname'],
takerAccountSequence: json['takerAccountSequence'],
takerPhone: json['takerPhone'],
takerNickname: json['takerNickname'],
price: json['price']?.toString() ?? '0',
quantity: json['quantity']?.toString() ?? '0',
totalAmount: json['totalAmount']?.toString() ?? '0',
minAmount: json['minAmount']?.toString() ?? '0',
maxAmount: json['maxAmount']?.toString() ?? '0',
status: _parseOrderStatus(json['status']),
remark: json['remark'],
createdAt: json['createdAt'] != null
? DateTime.parse(json['createdAt'])
: DateTime.now(),
matchedAt: json['matchedAt'] != null
? DateTime.parse(json['matchedAt'])
: null,
paidAt: json['paidAt'] != null
? DateTime.parse(json['paidAt'])
: null,
completedAt: json['completedAt'] != null
? DateTime.parse(json['completedAt'])
: null,
expiredAt: json['expiredAt'] != null
? DateTime.parse(json['expiredAt'])
: null,
);
}
static C2cOrderType _parseOrderType(String? type) {
switch (type?.toUpperCase()) {
case 'BUY':
return C2cOrderType.buy;
case 'SELL':
return C2cOrderType.sell;
default:
return C2cOrderType.buy;
}
}
static C2cOrderStatus _parseOrderStatus(String? status) {
switch (status?.toUpperCase()) {
case 'PENDING':
return C2cOrderStatus.pending;
case 'MATCHED':
return C2cOrderStatus.matched;
case 'PAID':
return C2cOrderStatus.paid;
case 'COMPLETED':
return C2cOrderStatus.completed;
case 'CANCELLED':
return C2cOrderStatus.cancelled;
case 'EXPIRED':
return C2cOrderStatus.expired;
default:
return C2cOrderStatus.pending;
}
}
bool get isBuy => type == C2cOrderType.buy;
bool get isSell => type == C2cOrderType.sell;
bool get isPending => status == C2cOrderStatus.pending;
bool get isMatched => status == C2cOrderStatus.matched;
bool get isPaid => status == C2cOrderStatus.paid;
bool get isCompleted => status == C2cOrderStatus.completed;
bool get isCancelled => status == C2cOrderStatus.cancelled;
String get typeText => isBuy ? '买入' : '卖出';
String get statusText {
switch (status) {
case C2cOrderStatus.pending:
return '待接单';
case C2cOrderStatus.matched:
return '待付款';
case C2cOrderStatus.paid:
return '待确认';
case C2cOrderStatus.completed:
return '已完成';
case C2cOrderStatus.cancelled:
return '已取消';
case C2cOrderStatus.expired:
return '已过期';
}
}
}
/// C2C订单列表分页模型
class C2cOrdersPageModel {
final List<C2cOrderModel> data;
final int total;
final int page;
final int pageSize;
C2cOrdersPageModel({
required this.data,
required this.total,
required this.page,
required this.pageSize,
});
factory C2cOrdersPageModel.fromJson(Map<String, dynamic> json) {
final List<dynamic> dataList = json['data'] ?? [];
return C2cOrdersPageModel(
data: dataList.map((e) => C2cOrderModel.fromJson(e)).toList(),
total: json['total'] ?? 0,
page: json['page'] ?? 1,
pageSize: json['pageSize'] ?? 10,
);
}
}

View File

@ -0,0 +1,61 @@
/// P2P转账记录模型
class P2pTransferModel {
final String transferNo;
final String fromAccountSequence;
final String toAccountSequence;
final String toPhone;
final String amount;
final String? memo;
final String status;
final DateTime createdAt;
P2pTransferModel({
required this.transferNo,
required this.fromAccountSequence,
required this.toAccountSequence,
required this.toPhone,
required this.amount,
this.memo,
required this.status,
required this.createdAt,
});
factory P2pTransferModel.fromJson(Map<String, dynamic> json) {
return P2pTransferModel(
transferNo: json['transferNo'] ?? '',
fromAccountSequence: json['fromAccountSequence'] ?? '',
toAccountSequence: json['toAccountSequence'] ?? '',
toPhone: json['toPhone'] ?? '',
amount: json['amount']?.toString() ?? '0',
memo: json['memo'],
status: json['status'] ?? 'PENDING',
createdAt: json['createdAt'] != null
? DateTime.parse(json['createdAt'])
: DateTime.now(),
);
}
}
///
class AccountLookupModel {
final String accountSequence;
final String phone;
final String? nickname;
final bool exists;
AccountLookupModel({
required this.accountSequence,
required this.phone,
this.nickname,
required this.exists,
});
factory AccountLookupModel.fromJson(Map<String, dynamic> json) {
return AccountLookupModel(
accountSequence: json['accountSequence'] ?? '',
phone: json['phone'] ?? '',
nickname: json['nickname'],
exists: json['exists'] ?? false,
);
}
}

View File

@ -1,5 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/router/routes.dart';
import '../../../core/utils/format_utils.dart';
import '../../../domain/entities/asset_display.dart';
import '../../providers/user_providers.dart';
@ -60,7 +62,7 @@ class AssetPage extends ConsumerWidget {
_buildTotalAssetCard(asset, isLoading),
const SizedBox(height: 24),
//
_buildQuickActions(),
_buildQuickActions(context),
const SizedBox(height: 24),
// -
_buildAssetList(asset, isLoading),
@ -237,39 +239,63 @@ class AssetPage extends ConsumerWidget {
);
}
Widget _buildQuickActions() {
Widget _buildQuickActions(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_buildQuickActionItem(Icons.add, '接收', _orange),
_buildQuickActionItem(Icons.remove, '发送', _orange),
_buildQuickActionItem(Icons.people_outline, 'C2C', _orange),
_buildQuickActionItem(
Icons.add,
'接收',
_orange,
() => context.push(Routes.receiveShares),
),
_buildQuickActionItem(
Icons.remove,
'发送',
_orange,
() => context.push(Routes.sendShares),
),
_buildQuickActionItem(
Icons.people_outline,
'C2C',
_orange,
() => context.push(Routes.c2cMarket),
),
],
);
}
Widget _buildQuickActionItem(IconData icon, String label, Color color) {
return Column(
children: [
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: _serenade,
borderRadius: BorderRadius.circular(16),
Widget _buildQuickActionItem(
IconData icon,
String label,
Color color,
VoidCallback onTap,
) {
return GestureDetector(
onTap: onTap,
behavior: HitTestBehavior.opaque,
child: Column(
children: [
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: _serenade,
borderRadius: BorderRadius.circular(16),
),
child: Icon(icon, color: color, size: 24),
),
child: Icon(icon, color: color, size: 24),
),
const SizedBox(height: 8),
Text(
label,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: _riverBed,
const SizedBox(height: 8),
Text(
label,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: _riverBed,
),
),
),
],
],
),
);
}

View File

@ -0,0 +1,291 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:qr_flutter/qr_flutter.dart';
import '../../providers/user_providers.dart';
class ReceiveSharesPage extends ConsumerWidget {
const ReceiveSharesPage({super.key});
static const Color _orange = Color(0xFFFF6B00);
static const Color _green = Color(0xFF10B981);
static const Color _darkText = Color(0xFF1F2937);
static const Color _grayText = Color(0xFF6B7280);
static const Color _bgGray = Color(0xFFF3F4F6);
@override
Widget build(BuildContext context, WidgetRef ref) {
final user = ref.watch(userNotifierProvider);
final phone = user.phone ?? '';
final nickname = user.nickname ?? user.realName ?? '榴莲用户';
return Scaffold(
backgroundColor: _bgGray,
appBar: AppBar(
backgroundColor: Colors.white,
elevation: 0,
leading: IconButton(
icon: const Icon(Icons.arrow_back, color: _darkText),
onPressed: () => context.pop(),
),
title: const Text(
'接收积分股',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: _darkText,
),
),
centerTitle: true,
),
body: SingleChildScrollView(
child: Column(
children: [
const SizedBox(height: 32),
//
_buildQrCodeCard(context, phone, nickname),
const SizedBox(height: 24),
//
_buildPhoneCard(context, phone),
const SizedBox(height: 24),
// 使
_buildInstructions(),
],
),
),
);
}
Widget _buildQrCodeCard(BuildContext context, String phone, String nickname) {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 32),
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 20,
offset: const Offset(0, 4),
),
],
),
child: Column(
children: [
//
Container(
height: 4,
margin: const EdgeInsets.only(bottom: 20),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFFFF6B00), Color(0xFFFDBA74)],
),
borderRadius: BorderRadius.circular(2),
),
),
//
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: LinearGradient(
colors: [_orange.withOpacity(0.8), _orange],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
child: Center(
child: Text(
nickname.isNotEmpty ? nickname[0].toUpperCase() : 'U',
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
),
),
const SizedBox(width: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
nickname,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: _darkText,
),
),
const SizedBox(height: 2),
const Text(
'扫码向我转账',
style: TextStyle(
fontSize: 12,
color: _grayText,
),
),
],
),
],
),
const SizedBox(height: 24),
//
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: _orange.withOpacity(0.2),
width: 2,
),
),
child: QrImageView(
data: 'durian://transfer?phone=$phone',
version: QrVersions.auto,
size: 200,
backgroundColor: Colors.white,
eyeStyle: const QrEyeStyle(
eyeShape: QrEyeShape.square,
color: _darkText,
),
dataModuleStyle: const QrDataModuleStyle(
dataModuleShape: QrDataModuleShape.square,
color: _darkText,
),
embeddedImage: null,
embeddedImageStyle: null,
),
),
const SizedBox(height: 16),
//
const Text(
'让对方扫描二维码向您转账',
style: TextStyle(
fontSize: 14,
color: _grayText,
),
),
],
),
);
}
Widget _buildPhoneCard(BuildContext context, String phone) {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 32),
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
),
child: Column(
children: [
const Text(
'或告诉对方您的手机号',
style: TextStyle(
fontSize: 14,
color: _grayText,
),
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
phone,
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: _darkText,
letterSpacing: 2,
),
),
const SizedBox(width: 12),
IconButton(
onPressed: () {
Clipboard.setData(ClipboardData(text: phone));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('手机号已复制'),
backgroundColor: _green,
duration: Duration(seconds: 1),
),
);
},
icon: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: _orange.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: const Icon(
Icons.copy,
color: _orange,
size: 20,
),
),
),
],
),
],
),
);
}
Widget _buildInstructions() {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 32),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: _orange.withOpacity(0.05),
borderRadius: BorderRadius.circular(12),
),
child: const Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.info_outline, size: 16, color: _orange),
SizedBox(width: 8),
Text(
'使用说明',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: _orange,
),
),
],
),
SizedBox(height: 8),
Text(
'1. 让对方使用榴莲APP扫描二维码\n2. 或者将您的手机号告诉对方\n3. 对方可以通过手机号向您转账',
style: TextStyle(
fontSize: 12,
color: _grayText,
height: 1.5,
),
),
],
),
);
}
}

View File

@ -0,0 +1,551 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/utils/format_utils.dart';
import '../../providers/user_providers.dart';
import '../../providers/asset_providers.dart';
import '../../providers/transfer_providers.dart';
class SendSharesPage extends ConsumerStatefulWidget {
const SendSharesPage({super.key});
@override
ConsumerState<SendSharesPage> createState() => _SendSharesPageState();
}
class _SendSharesPageState extends ConsumerState<SendSharesPage> {
static const Color _orange = Color(0xFFFF6B00);
static const Color _green = Color(0xFF10B981);
static const Color _darkText = Color(0xFF1F2937);
static const Color _grayText = Color(0xFF6B7280);
static const Color _bgGray = Color(0xFFF3F4F6);
static const Color _red = Color(0xFFEF4444);
final _phoneController = TextEditingController();
final _amountController = TextEditingController();
final _memoController = TextEditingController();
bool _isRecipientVerified = false;
String? _recipientNickname;
@override
void dispose() {
_phoneController.dispose();
_amountController.dispose();
_memoController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final user = ref.watch(userNotifierProvider);
final accountSequence = user.accountSequence ?? '';
final assetAsync = ref.watch(accountAssetProvider(accountSequence));
final transferState = ref.watch(transferNotifierProvider);
final availableShares = assetAsync.valueOrNull?.availableShares ?? '0';
return Scaffold(
backgroundColor: _bgGray,
appBar: AppBar(
backgroundColor: Colors.white,
elevation: 0,
leading: IconButton(
icon: const Icon(Icons.arrow_back, color: _darkText),
onPressed: () => context.pop(),
),
title: const Text(
'发送积分股',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: _darkText,
),
),
centerTitle: true,
),
body: SingleChildScrollView(
child: Column(
children: [
const SizedBox(height: 16),
//
_buildRecipientSection(transferState),
const SizedBox(height: 16),
//
_buildAmountSection(availableShares),
const SizedBox(height: 16),
//
_buildMemoSection(),
const SizedBox(height: 32),
//
_buildSendButton(transferState, availableShares),
const SizedBox(height: 16),
//
_buildTips(),
],
),
),
);
}
Widget _buildRecipientSection(TransferState transferState) {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'收款方手机号',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: _darkText,
),
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: TextField(
controller: _phoneController,
keyboardType: TextInputType.phone,
maxLength: 11,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
decoration: InputDecoration(
hintText: '请输入收款方手机号',
hintStyle: const TextStyle(color: _grayText),
filled: true,
fillColor: _bgGray,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: _orange, width: 2),
),
counterText: '',
suffixIcon: _phoneController.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear, color: _grayText),
onPressed: () {
setState(() {
_phoneController.clear();
_isRecipientVerified = false;
_recipientNickname = null;
});
},
)
: null,
),
onChanged: (value) {
setState(() {
_isRecipientVerified = false;
_recipientNickname = null;
});
},
),
),
const SizedBox(width: 12),
SizedBox(
height: 48,
child: ElevatedButton(
onPressed: transferState.isLoading
? null
: _verifyRecipient,
style: ElevatedButton.styleFrom(
backgroundColor: _orange,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
padding: const EdgeInsets.symmetric(horizontal: 16),
),
child: transferState.isLoading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Text('验证'),
),
),
],
),
if (_isRecipientVerified && _recipientNickname != null) ...[
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: _green.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
const Icon(Icons.check_circle, color: _green, size: 20),
const SizedBox(width: 8),
Expanded(
child: Text(
'收款方: $_recipientNickname',
style: const TextStyle(
fontSize: 14,
color: _green,
fontWeight: FontWeight.w500,
),
),
),
],
),
),
],
if (transferState.error != null &&
!_isRecipientVerified &&
_phoneController.text.length == 11) ...[
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: _red.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
const Icon(Icons.error_outline, color: _red, size: 20),
const SizedBox(width: 8),
Expanded(
child: Text(
'用户不存在或无法转账',
style: const TextStyle(
fontSize: 14,
color: _red,
),
),
),
],
),
),
],
],
),
);
}
Widget _buildAmountSection(String availableShares) {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'转账数量',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: _darkText,
),
),
Text(
'可用: ${formatAmount(availableShares)}',
style: const TextStyle(
fontSize: 12,
color: _grayText,
),
),
],
),
const SizedBox(height: 12),
TextField(
controller: _amountController,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
inputFormatters: [
FilteringTextInputFormatter.allow(RegExp(r'^\d+\.?\d{0,8}')),
],
decoration: InputDecoration(
hintText: '请输入转账数量',
hintStyle: const TextStyle(color: _grayText),
filled: true,
fillColor: _bgGray,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: _orange, width: 2),
),
suffixIcon: TextButton(
onPressed: () {
_amountController.text = availableShares;
},
child: const Text(
'全部',
style: TextStyle(
color: _orange,
fontWeight: FontWeight.w500,
),
),
),
),
onChanged: (value) {
setState(() {});
},
),
],
),
);
}
Widget _buildMemoSection() {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'备注 (可选)',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: _darkText,
),
),
const SizedBox(height: 12),
TextField(
controller: _memoController,
maxLength: 50,
decoration: InputDecoration(
hintText: '添加备注信息',
hintStyle: const TextStyle(color: _grayText),
filled: true,
fillColor: _bgGray,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: _orange, width: 2),
),
counterStyle: const TextStyle(color: _grayText),
),
),
],
),
);
}
Widget _buildSendButton(TransferState transferState, String availableShares) {
final amount = double.tryParse(_amountController.text) ?? 0;
final available = double.tryParse(availableShares) ?? 0;
final isValid = _isRecipientVerified && amount > 0 && amount <= available;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: SizedBox(
width: double.infinity,
height: 50,
child: ElevatedButton(
onPressed: isValid && !transferState.isLoading
? _handleTransfer
: null,
style: ElevatedButton.styleFrom(
backgroundColor: _orange,
foregroundColor: Colors.white,
disabledBackgroundColor: _orange.withOpacity(0.4),
disabledForegroundColor: Colors.white70,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: transferState.isLoading
? const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Text(
'确认发送',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
),
),
);
}
Widget _buildTips() {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: _orange.withOpacity(0.05),
borderRadius: BorderRadius.circular(12),
),
child: const Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.info_outline, size: 16, color: _orange),
SizedBox(width: 8),
Text(
'温馨提示',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: _orange,
),
),
],
),
SizedBox(height: 8),
Text(
'1. 转账前请确认收款方手机号正确\n2. 积分股转账不可撤销,请谨慎操作\n3. 转账后将从您的可用积分股中扣除',
style: TextStyle(
fontSize: 12,
color: _grayText,
height: 1.5,
),
),
],
),
);
}
Future<void> _verifyRecipient() async {
final phone = _phoneController.text.trim();
if (phone.length != 11) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('请输入正确的11位手机号'),
backgroundColor: _red,
),
);
return;
}
//
final user = ref.read(userNotifierProvider);
if (phone == user.phone) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('不能转账给自己'),
backgroundColor: _red,
),
);
return;
}
final notifier = ref.read(transferNotifierProvider.notifier);
final account = await notifier.lookupRecipient(phone);
if (account != null && account.exists) {
setState(() {
_isRecipientVerified = true;
_recipientNickname = account.nickname ?? _maskPhone(phone);
});
} else {
setState(() {
_isRecipientVerified = false;
_recipientNickname = null;
});
}
}
String _maskPhone(String phone) {
if (phone.length != 11) return phone;
return '${phone.substring(0, 3)}****${phone.substring(7)}';
}
Future<void> _handleTransfer() async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('确认转账'),
content: Text(
'确定要向 $_recipientNickname 发送 ${_amountController.text} 积分股吗?\n\n此操作不可撤销。',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('取消'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text(
'确认',
style: TextStyle(color: _orange),
),
),
],
),
);
if (confirmed != true) return;
final notifier = ref.read(transferNotifierProvider.notifier);
final success = await notifier.transfer(
toPhone: _phoneController.text.trim(),
amount: _amountController.text.trim(),
memo: _memoController.text.trim().isEmpty ? null : _memoController.text.trim(),
);
if (success && mounted) {
//
final user = ref.read(userNotifierProvider);
ref.invalidate(accountAssetProvider(user.accountSequence ?? ''));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('转账成功'),
backgroundColor: _green,
),
);
context.pop();
} else if (mounted) {
final error = ref.read(transferNotifierProvider).error;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('转账失败: ${error ?? '未知错误'}'),
backgroundColor: _red,
),
);
}
}
}

View File

@ -0,0 +1,658 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/router/routes.dart';
import '../../../core/utils/format_utils.dart';
import '../../../data/models/c2c_order_model.dart';
import '../../providers/c2c_providers.dart';
import '../../providers/user_providers.dart';
import '../../providers/asset_providers.dart';
class C2cMarketPage extends ConsumerStatefulWidget {
const C2cMarketPage({super.key});
@override
ConsumerState<C2cMarketPage> createState() => _C2cMarketPageState();
}
class _C2cMarketPageState extends ConsumerState<C2cMarketPage>
with SingleTickerProviderStateMixin {
static const Color _orange = Color(0xFFFF6B00);
static const Color _green = Color(0xFF10B981);
static const Color _red = Color(0xFFEF4444);
static const Color _darkText = Color(0xFF1F2937);
static const Color _grayText = Color(0xFF6B7280);
static const Color _bgGray = Color(0xFFF3F4F6);
late TabController _tabController;
int _currentTab = 0; // 0: , 1: , 2:
@override
void initState() {
super.initState();
_tabController = TabController(length: 3, vsync: this);
_tabController.addListener(() {
setState(() {
_currentTab = _tabController.index;
});
});
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final user = ref.watch(userNotifierProvider);
final accountSequence = user.accountSequence ?? '';
final assetAsync = ref.watch(accountAssetProvider(accountSequence));
final asset = assetAsync.valueOrNull;
return Scaffold(
backgroundColor: _bgGray,
appBar: AppBar(
backgroundColor: Colors.white,
elevation: 0,
leading: IconButton(
icon: const Icon(Icons.arrow_back, color: _darkText),
onPressed: () => context.pop(),
),
title: const Text(
'C2C交易',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: _darkText,
),
),
centerTitle: true,
actions: [
IconButton(
icon: const Icon(Icons.add_circle_outline, color: _orange),
onPressed: () => context.push(Routes.c2cPublish),
),
],
bottom: TabBar(
controller: _tabController,
labelColor: _orange,
unselectedLabelColor: _grayText,
indicatorColor: _orange,
indicatorWeight: 3,
tabs: const [
Tab(text: '购买'),
Tab(text: '出售'),
Tab(text: '我的'),
],
),
),
body: Column(
children: [
//
_buildAssetOverview(asset),
//
Expanded(
child: TabBarView(
controller: _tabController,
children: [
_buildOrderList('SELL'), //
_buildOrderList('BUY'), //
_buildMyOrderList(),
],
),
),
],
),
);
}
Widget _buildAssetOverview(asset) {
final availableShares = asset?.availableShares ?? '0';
final availableCash = asset?.availableCash ?? '0';
return Container(
margin: const EdgeInsets.all(16),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'可用积分股',
style: TextStyle(fontSize: 12, color: _grayText),
),
const SizedBox(height: 4),
Text(
formatAmount(availableShares),
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: _orange,
),
),
],
),
),
Container(width: 1, height: 40, color: _bgGray),
Expanded(
child: Padding(
padding: const EdgeInsets.only(left: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'可用积分值',
style: TextStyle(fontSize: 12, color: _grayText),
),
const SizedBox(height: 4),
Text(
formatAmount(availableCash),
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: _green,
),
),
],
),
),
),
],
),
);
}
Widget _buildOrderList(String type) {
final ordersAsync = ref.watch(c2cOrdersProvider(type));
return ordersAsync.when(
loading: () => const Center(
child: CircularProgressIndicator(color: _orange),
),
error: (error, stack) => Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline, size: 48, color: _grayText),
const SizedBox(height: 8),
Text('加载失败', style: TextStyle(color: _grayText)),
const SizedBox(height: 8),
ElevatedButton(
onPressed: () => ref.invalidate(c2cOrdersProvider(type)),
child: const Text('重试'),
),
],
),
),
data: (ordersPage) {
final orders = ordersPage.data;
if (orders.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.inbox_outlined, size: 64, color: _grayText.withOpacity(0.5)),
const SizedBox(height: 16),
Text(
type == 'SELL' ? '暂无出售广告' : '暂无购买广告',
style: TextStyle(fontSize: 16, color: _grayText),
),
const SizedBox(height: 8),
Text(
'点击右上角发布广告',
style: TextStyle(fontSize: 14, color: _grayText.withOpacity(0.7)),
),
],
),
);
}
return RefreshIndicator(
onRefresh: () async {
ref.invalidate(c2cOrdersProvider(type));
},
child: ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: orders.length,
itemBuilder: (context, index) {
final order = orders[index];
return _buildOrderCard(order, type == 'SELL');
},
),
);
},
);
}
Widget _buildMyOrderList() {
final ordersAsync = ref.watch(myC2cOrdersProvider);
return ordersAsync.when(
loading: () => const Center(
child: CircularProgressIndicator(color: _orange),
),
error: (error, stack) => Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline, size: 48, color: _grayText),
const SizedBox(height: 8),
Text('加载失败', style: TextStyle(color: _grayText)),
],
),
),
data: (ordersPage) {
final orders = ordersPage.data;
if (orders.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.history, size: 64, color: _grayText.withOpacity(0.5)),
const SizedBox(height: 16),
Text(
'暂无订单记录',
style: TextStyle(fontSize: 16, color: _grayText),
),
],
),
);
}
return RefreshIndicator(
onRefresh: () async {
ref.invalidate(myC2cOrdersProvider);
},
child: ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: orders.length,
itemBuilder: (context, index) {
final order = orders[index];
return _buildMyOrderCard(order);
},
),
);
},
);
}
Widget _buildOrderCard(C2cOrderModel order, bool isBuyAction) {
final user = ref.read(userNotifierProvider);
final isMyOrder = order.makerAccountSequence == user.accountSequence;
return GestureDetector(
onTap: isMyOrder ? null : () => _showTakeOrderDialog(order, isBuyAction),
child: Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
//
Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: (isBuyAction ? _green : _red).withOpacity(0.1),
),
child: Center(
child: Text(
(order.makerNickname ?? order.makerPhone ?? 'U')[0].toUpperCase(),
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: isBuyAction ? _green : _red,
),
),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
order.makerNickname ?? _maskPhone(order.makerPhone ?? ''),
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: _darkText,
),
),
if (isMyOrder)
const Text(
'我的广告',
style: TextStyle(fontSize: 12, color: _orange),
),
],
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: (isBuyAction ? _green : _red).withOpacity(0.1),
borderRadius: BorderRadius.circular(4),
),
child: Text(
isBuyAction ? '购买' : '出售',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: isBuyAction ? _green : _red,
),
),
),
],
),
const SizedBox(height: 16),
//
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('单价', style: TextStyle(fontSize: 12, color: _grayText)),
const SizedBox(height: 4),
Text(
'${formatPrice(order.price)} 积分值',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: _darkText,
),
),
],
),
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('数量', style: TextStyle(fontSize: 12, color: _grayText)),
const SizedBox(height: 4),
Text(
'${formatAmount(order.quantity)} 积分股',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: _darkText,
),
),
],
),
),
],
),
const SizedBox(height: 12),
//
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: _bgGray,
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('总金额', style: TextStyle(fontSize: 12, color: _grayText)),
Text(
'${formatAmount(order.totalAmount)} 积分值',
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: _orange,
),
),
],
),
),
if (order.remark != null && order.remark!.isNotEmpty) ...[
const SizedBox(height: 8),
Text(
'备注: ${order.remark}',
style: TextStyle(fontSize: 12, color: _grayText),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
],
),
),
);
}
Widget _buildMyOrderCard(C2cOrderModel order) {
return GestureDetector(
onTap: () => context.push(Routes.c2cOrderDetail, extra: order.orderNo),
child: Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
//
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: (order.isBuy ? _green : _red).withOpacity(0.1),
borderRadius: BorderRadius.circular(4),
),
child: Text(
order.typeText,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: order.isBuy ? _green : _red,
),
),
),
const SizedBox(width: 8),
Text(
order.orderNo,
style: const TextStyle(fontSize: 12, color: _grayText),
),
],
),
_buildStatusBadge(order.status),
],
),
const SizedBox(height: 12),
//
Row(
children: [
Expanded(
child: Text(
'${formatAmount(order.quantity)} 积分股',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: _darkText,
),
),
),
Text(
'${formatAmount(order.totalAmount)} 积分值',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: _orange,
),
),
],
),
const SizedBox(height: 8),
//
Text(
'创建于 ${_formatDateTime(order.createdAt)}',
style: const TextStyle(fontSize: 12, color: _grayText),
),
],
),
),
);
}
Widget _buildStatusBadge(C2cOrderStatus status) {
Color bgColor;
Color textColor;
String text;
switch (status) {
case C2cOrderStatus.pending:
bgColor = _orange.withOpacity(0.1);
textColor = _orange;
text = '待接单';
break;
case C2cOrderStatus.matched:
bgColor = Colors.blue.withOpacity(0.1);
textColor = Colors.blue;
text = '待付款';
break;
case C2cOrderStatus.paid:
bgColor = Colors.purple.withOpacity(0.1);
textColor = Colors.purple;
text = '待确认';
break;
case C2cOrderStatus.completed:
bgColor = _green.withOpacity(0.1);
textColor = _green;
text = '已完成';
break;
case C2cOrderStatus.cancelled:
bgColor = _grayText.withOpacity(0.1);
textColor = _grayText;
text = '已取消';
break;
case C2cOrderStatus.expired:
bgColor = _red.withOpacity(0.1);
textColor = _red;
text = '已过期';
break;
}
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: bgColor,
borderRadius: BorderRadius.circular(4),
),
child: Text(
text,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: textColor,
),
),
);
}
String _maskPhone(String phone) {
if (phone.length != 11) return phone;
return '${phone.substring(0, 3)}****${phone.substring(7)}';
}
String _formatDateTime(DateTime dateTime) {
return '${dateTime.month}/${dateTime.day} ${dateTime.hour.toString().padLeft(2, '0')}:${dateTime.minute.toString().padLeft(2, '0')}';
}
void _showTakeOrderDialog(C2cOrderModel order, bool isBuyAction) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(isBuyAction ? '确认购买' : '确认出售'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('数量: ${formatAmount(order.quantity)} 积分股'),
const SizedBox(height: 8),
Text('单价: ${formatPrice(order.price)} 积分值'),
const SizedBox(height: 8),
Text(
'总金额: ${formatAmount(order.totalAmount)} 积分值',
style: const TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
Text(
isBuyAction
? '您将使用积分值购买对方的积分股'
: '您将出售积分股换取对方的积分值',
style: TextStyle(fontSize: 12, color: _grayText),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('取消'),
),
TextButton(
onPressed: () async {
Navigator.pop(context);
await _takeOrder(order);
},
child: Text(
'确认',
style: TextStyle(color: _orange),
),
),
],
),
);
}
Future<void> _takeOrder(C2cOrderModel order) async {
final notifier = ref.read(c2cTradingNotifierProvider.notifier);
final success = await notifier.takeOrder(order.orderNo);
if (success && mounted) {
//
ref.invalidate(c2cOrdersProvider(order.isBuy ? 'BUY' : 'SELL'));
ref.invalidate(myC2cOrdersProvider);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('接单成功,请按提示完成交易'),
backgroundColor: _green,
),
);
//
context.push(Routes.c2cOrderDetail, extra: order.orderNo);
} else if (mounted) {
final error = ref.read(c2cTradingNotifierProvider).error;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('接单失败: ${error ?? '未知错误'}'),
backgroundColor: _red,
),
);
}
}
}

View File

@ -0,0 +1,854 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/utils/format_utils.dart';
import '../../../data/models/c2c_order_model.dart';
import '../../providers/c2c_providers.dart';
import '../../providers/user_providers.dart';
import '../../providers/asset_providers.dart';
class C2cOrderDetailPage extends ConsumerStatefulWidget {
final String orderNo;
const C2cOrderDetailPage({super.key, required this.orderNo});
@override
ConsumerState<C2cOrderDetailPage> createState() => _C2cOrderDetailPageState();
}
class _C2cOrderDetailPageState extends ConsumerState<C2cOrderDetailPage> {
static const Color _orange = Color(0xFFFF6B00);
static const Color _green = Color(0xFF10B981);
static const Color _red = Color(0xFFEF4444);
static const Color _darkText = Color(0xFF1F2937);
static const Color _grayText = Color(0xFF6B7280);
static const Color _bgGray = Color(0xFFF3F4F6);
@override
Widget build(BuildContext context) {
final orderAsync = ref.watch(c2cOrderDetailProvider(widget.orderNo));
final c2cState = ref.watch(c2cTradingNotifierProvider);
return Scaffold(
backgroundColor: _bgGray,
appBar: AppBar(
backgroundColor: Colors.white,
elevation: 0,
leading: IconButton(
icon: const Icon(Icons.arrow_back, color: _darkText),
onPressed: () => context.pop(),
),
title: const Text(
'订单详情',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: _darkText,
),
),
centerTitle: true,
actions: [
IconButton(
icon: const Icon(Icons.refresh, color: _orange),
onPressed: () => ref.invalidate(c2cOrderDetailProvider(widget.orderNo)),
),
],
),
body: orderAsync.when(
loading: () => const Center(
child: CircularProgressIndicator(color: _orange),
),
error: (error, stack) => Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline, size: 48, color: _grayText),
const SizedBox(height: 8),
Text('加载失败: $error', style: const TextStyle(color: _grayText)),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () => ref.invalidate(c2cOrderDetailProvider(widget.orderNo)),
child: const Text('重试'),
),
],
),
),
data: (order) => _buildContent(order, c2cState),
),
);
}
Widget _buildContent(C2cOrderModel order, C2cTradingState c2cState) {
final user = ref.watch(userNotifierProvider);
final isMaker = order.makerAccountSequence == user.accountSequence;
final isTaker = order.takerAccountSequence == user.accountSequence;
final isBuyer = (order.isBuy && isMaker) || (order.isSell && isTaker);
return SingleChildScrollView(
child: Column(
children: [
const SizedBox(height: 16),
//
_buildStatusCard(order),
const SizedBox(height: 16),
//
_buildOrderInfoCard(order, isMaker),
const SizedBox(height: 16),
//
_buildParticipantsCard(order, isMaker, isTaker),
const SizedBox(height: 16),
//
_buildInstructionsCard(order, isMaker, isTaker, isBuyer),
const SizedBox(height: 24),
//
_buildActionButtons(order, isMaker, isTaker, isBuyer, c2cState),
const SizedBox(height: 24),
],
),
);
}
Widget _buildStatusCard(C2cOrderModel order) {
Color statusColor;
String statusText;
String statusDesc;
IconData statusIcon;
switch (order.status) {
case C2cOrderStatus.pending:
statusColor = _orange;
statusText = '待接单';
statusDesc = '等待其他用户接单...';
statusIcon = Icons.hourglass_empty;
break;
case C2cOrderStatus.matched:
statusColor = Colors.blue;
statusText = '待付款';
statusDesc = '买方需在规定时间内付款';
statusIcon = Icons.payment;
break;
case C2cOrderStatus.paid:
statusColor = Colors.purple;
statusText = '待确认';
statusDesc = '卖方需确认收款后释放资产';
statusIcon = Icons.check_circle_outline;
break;
case C2cOrderStatus.completed:
statusColor = _green;
statusText = '已完成';
statusDesc = '交易已完成';
statusIcon = Icons.verified;
break;
case C2cOrderStatus.cancelled:
statusColor = _grayText;
statusText = '已取消';
statusDesc = '订单已取消';
statusIcon = Icons.cancel_outlined;
break;
case C2cOrderStatus.expired:
statusColor = _red;
statusText = '已过期';
statusDesc = '订单已过期';
statusIcon = Icons.timer_off;
break;
}
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
),
child: Column(
children: [
Container(
width: 64,
height: 64,
decoration: BoxDecoration(
color: statusColor.withOpacity(0.1),
shape: BoxShape.circle,
),
child: Icon(statusIcon, size: 32, color: statusColor),
),
const SizedBox(height: 16),
Text(
statusText,
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: statusColor,
),
),
const SizedBox(height: 8),
Text(
statusDesc,
style: const TextStyle(fontSize: 14, color: _grayText),
),
],
),
);
}
Widget _buildOrderInfoCard(C2cOrderModel order, bool isMaker) {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: (order.isBuy ? _green : _red).withOpacity(0.1),
borderRadius: BorderRadius.circular(4),
),
child: Text(
order.typeText,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: order.isBuy ? _green : _red,
),
),
),
const SizedBox(width: 8),
if (isMaker)
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: _orange.withOpacity(0.1),
borderRadius: BorderRadius.circular(4),
),
child: const Text(
'我发布的',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: _orange,
),
),
),
],
),
const SizedBox(height: 16),
_buildInfoRow('订单编号', order.orderNo, canCopy: true),
_buildInfoRow('单价', '${formatPrice(order.price)} 积分值/股'),
_buildInfoRow('数量', '${formatAmount(order.quantity)} 积分股'),
_buildInfoRow('总金额', '${formatAmount(order.totalAmount)} 积分值'),
_buildInfoRow('创建时间', _formatDateTime(order.createdAt)),
if (order.remark != null && order.remark!.isNotEmpty)
_buildInfoRow('备注', order.remark!),
],
),
);
}
Widget _buildInfoRow(String label, String value, {bool canCopy = false}) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: const TextStyle(fontSize: 14, color: _grayText),
),
const SizedBox(width: 16),
Expanded(
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Flexible(
child: Text(
value,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: _darkText,
),
textAlign: TextAlign.right,
),
),
if (canCopy) ...[
const SizedBox(width: 4),
GestureDetector(
onTap: () {
Clipboard.setData(ClipboardData(text: value));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('已复制'),
duration: Duration(seconds: 1),
),
);
},
child: const Icon(Icons.copy, size: 16, color: _grayText),
),
],
],
),
),
],
),
);
}
Widget _buildParticipantsCard(C2cOrderModel order, bool isMaker, bool isTaker) {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'交易双方',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: _darkText,
),
),
const SizedBox(height: 16),
//
_buildParticipantRow(
label: order.isBuy ? '买方 (发布者)' : '卖方 (发布者)',
name: order.makerNickname ?? _maskPhone(order.makerPhone ?? ''),
isMe: isMaker,
),
const Divider(height: 24),
//
if (order.takerAccountSequence != null)
_buildParticipantRow(
label: order.isBuy ? '卖方 (接单者)' : '买方 (接单者)',
name: order.takerNickname ?? _maskPhone(order.takerPhone ?? ''),
isMe: isTaker,
)
else
const Text(
'等待接单...',
style: TextStyle(fontSize: 14, color: _grayText),
),
],
),
);
}
Widget _buildParticipantRow({
required String label,
required String name,
required bool isMe,
}) {
return Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: (isMe ? _orange : _grayText).withOpacity(0.1),
),
child: Center(
child: Text(
name[0].toUpperCase(),
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: isMe ? _orange : _grayText,
),
),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: const TextStyle(fontSize: 12, color: _grayText),
),
const SizedBox(height: 2),
Row(
children: [
Text(
name,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: _darkText,
),
),
if (isMe) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: _orange.withOpacity(0.1),
borderRadius: BorderRadius.circular(4),
),
child: const Text(
'',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: _orange,
),
),
),
],
],
),
],
),
),
],
);
}
Widget _buildInstructionsCard(
C2cOrderModel order,
bool isMaker,
bool isTaker,
bool isBuyer,
) {
if (order.status == C2cOrderStatus.completed ||
order.status == C2cOrderStatus.cancelled ||
order.status == C2cOrderStatus.expired) {
return const SizedBox.shrink();
}
String title;
List<String> instructions;
switch (order.status) {
case C2cOrderStatus.pending:
title = '操作说明';
instructions = isMaker
? ['等待其他用户接单', '您也可以取消此订单']
: ['此订单尚未被接单'];
break;
case C2cOrderStatus.matched:
title = isBuyer ? '付款说明' : '等待买方付款';
instructions = isBuyer
? [
'请通过线下方式向卖方转账',
'转账金额: ${formatAmount(order.totalAmount)} 积分值',
'转账完成后点击"已付款"',
'注意: 请确保已完成转账再点击确认',
]
: [
'请等待买方完成付款',
'买方付款后您将收到通知',
'确认收款后请及时确认释放',
];
break;
case C2cOrderStatus.paid:
title = isBuyer ? '等待卖方确认' : '确认收款';
instructions = isBuyer
? [
'您已确认付款',
'请等待卖方确认收款',
'卖方确认后积分股将自动划转到您的账户',
]
: [
'买方已确认付款',
'请检查您是否已收到转账',
'确认收款后积分股将自动划转给买方',
];
break;
default:
title = '';
instructions = [];
}
if (instructions.isEmpty) return const SizedBox.shrink();
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: _orange.withOpacity(0.05),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: _orange.withOpacity(0.2)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.info_outline, size: 18, color: _orange),
const SizedBox(width: 8),
Text(
title,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: _orange,
),
),
],
),
const SizedBox(height: 12),
...instructions.map((text) => Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('', style: TextStyle(color: _grayText)),
Expanded(
child: Text(
text,
style: const TextStyle(
fontSize: 13,
color: _grayText,
height: 1.4,
),
),
),
],
),
)),
],
),
);
}
Widget _buildActionButtons(
C2cOrderModel order,
bool isMaker,
bool isTaker,
bool isBuyer,
C2cTradingState c2cState,
) {
final List<Widget> buttons = [];
switch (order.status) {
case C2cOrderStatus.pending:
if (isMaker) {
buttons.add(_buildActionButton(
label: '取消订单',
color: _red,
isLoading: c2cState.isLoading,
onPressed: () => _handleCancel(order),
));
}
break;
case C2cOrderStatus.matched:
if (isBuyer) {
buttons.add(_buildActionButton(
label: '已付款',
color: _green,
isLoading: c2cState.isLoading,
onPressed: () => _handleConfirmPayment(order),
));
}
//
if (!isBuyer && (isMaker || isTaker)) {
buttons.add(_buildActionButton(
label: '取消订单',
color: _red,
isOutlined: true,
isLoading: c2cState.isLoading,
onPressed: () => _handleCancel(order),
));
}
break;
case C2cOrderStatus.paid:
if (!isBuyer && (isMaker || isTaker)) {
buttons.add(_buildActionButton(
label: '确认收款并释放',
color: _green,
isLoading: c2cState.isLoading,
onPressed: () => _handleConfirmReceived(order),
));
}
break;
case C2cOrderStatus.completed:
case C2cOrderStatus.cancelled:
case C2cOrderStatus.expired:
//
break;
}
if (buttons.isEmpty) {
return const SizedBox.shrink();
}
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
children: buttons,
),
);
}
Widget _buildActionButton({
required String label,
required Color color,
required bool isLoading,
required VoidCallback onPressed,
bool isOutlined = false,
}) {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: SizedBox(
width: double.infinity,
height: 50,
child: isOutlined
? OutlinedButton(
onPressed: isLoading ? null : onPressed,
style: OutlinedButton.styleFrom(
foregroundColor: color,
side: BorderSide(color: color),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: isLoading
? SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
strokeWidth: 2,
color: color,
),
)
: Text(
label,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
)
: ElevatedButton(
onPressed: isLoading ? null : onPressed,
style: ElevatedButton.styleFrom(
backgroundColor: color,
foregroundColor: Colors.white,
disabledBackgroundColor: color.withOpacity(0.4),
disabledForegroundColor: Colors.white70,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: isLoading
? const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: Text(
label,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
),
),
);
}
String _maskPhone(String phone) {
if (phone.length != 11) return phone;
return '${phone.substring(0, 3)}****${phone.substring(7)}';
}
String _formatDateTime(DateTime dateTime) {
return '${dateTime.year}-${dateTime.month.toString().padLeft(2, '0')}-${dateTime.day.toString().padLeft(2, '0')} '
'${dateTime.hour.toString().padLeft(2, '0')}:${dateTime.minute.toString().padLeft(2, '0')}';
}
Future<void> _handleCancel(C2cOrderModel order) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('确认取消'),
content: const Text('确定要取消此订单吗?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('暂不取消'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: Text('确认取消', style: TextStyle(color: _red)),
),
],
),
);
if (confirmed != true) return;
final notifier = ref.read(c2cTradingNotifierProvider.notifier);
final success = await notifier.cancelOrder(order.orderNo);
if (success && mounted) {
ref.invalidate(c2cOrderDetailProvider(order.orderNo));
ref.invalidate(myC2cOrdersProvider);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('订单已取消'),
backgroundColor: _green,
),
);
} else if (mounted) {
final error = ref.read(c2cTradingNotifierProvider).error;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('取消失败: ${error ?? '未知错误'}'),
backgroundColor: _red,
),
);
}
}
Future<void> _handleConfirmPayment(C2cOrderModel order) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('确认付款'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('请确认您已完成以下付款:'),
const SizedBox(height: 16),
Text('金额: ${formatAmount(order.totalAmount)} 积分值'),
const SizedBox(height: 16),
const Text(
'注意:请确保已完成线下转账,虚假确认可能导致资产损失。',
style: TextStyle(fontSize: 12, color: _red),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('暂未付款'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: Text('确认已付款', style: TextStyle(color: _green)),
),
],
),
);
if (confirmed != true) return;
final notifier = ref.read(c2cTradingNotifierProvider.notifier);
final success = await notifier.confirmPayment(order.orderNo);
if (success && mounted) {
ref.invalidate(c2cOrderDetailProvider(order.orderNo));
ref.invalidate(myC2cOrdersProvider);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('已确认付款,请等待卖方确认收款'),
backgroundColor: _green,
),
);
} else if (mounted) {
final error = ref.read(c2cTradingNotifierProvider).error;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('确认失败: ${error ?? '未知错误'}'),
backgroundColor: _red,
),
);
}
}
Future<void> _handleConfirmReceived(C2cOrderModel order) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('确认收款'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('请确认您已收到买方的付款:'),
const SizedBox(height: 16),
Text('金额: ${formatAmount(order.totalAmount)} 积分值'),
const SizedBox(height: 16),
const Text(
'确认后,您的积分股将自动划转给买方,此操作不可撤销。',
style: TextStyle(fontSize: 12, color: _red),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('暂未收款'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: Text('确认收款并释放', style: TextStyle(color: _green)),
),
],
),
);
if (confirmed != true) return;
final notifier = ref.read(c2cTradingNotifierProvider.notifier);
final success = await notifier.confirmReceived(order.orderNo);
if (success && mounted) {
ref.invalidate(c2cOrderDetailProvider(order.orderNo));
ref.invalidate(myC2cOrdersProvider);
//
final user = ref.read(userNotifierProvider);
ref.invalidate(accountAssetProvider(user.accountSequence ?? ''));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('交易完成,资产已划转'),
backgroundColor: _green,
),
);
} else if (mounted) {
final error = ref.read(c2cTradingNotifierProvider).error;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('确认失败: ${error ?? '未知错误'}'),
backgroundColor: _red,
),
);
}
}
}

View File

@ -0,0 +1,605 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/utils/format_utils.dart';
import '../../providers/c2c_providers.dart';
import '../../providers/user_providers.dart';
import '../../providers/asset_providers.dart';
import '../../providers/trading_providers.dart';
class C2cPublishPage extends ConsumerStatefulWidget {
const C2cPublishPage({super.key});
@override
ConsumerState<C2cPublishPage> createState() => _C2cPublishPageState();
}
class _C2cPublishPageState extends ConsumerState<C2cPublishPage> {
static const Color _orange = Color(0xFFFF6B00);
static const Color _green = Color(0xFF10B981);
static const Color _red = Color(0xFFEF4444);
static const Color _darkText = Color(0xFF1F2937);
static const Color _grayText = Color(0xFF6B7280);
static const Color _bgGray = Color(0xFFF3F4F6);
int _selectedType = 1; // 0: , 1:
final _priceController = TextEditingController();
final _quantityController = TextEditingController();
final _remarkController = TextEditingController();
@override
void dispose() {
_priceController.dispose();
_quantityController.dispose();
_remarkController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final user = ref.watch(userNotifierProvider);
final accountSequence = user.accountSequence ?? '';
final assetAsync = ref.watch(accountAssetProvider(accountSequence));
final priceAsync = ref.watch(currentPriceProvider);
final c2cState = ref.watch(c2cTradingNotifierProvider);
final asset = assetAsync.valueOrNull;
final currentPrice = priceAsync.valueOrNull?.price ?? '0';
final availableShares = asset?.availableShares ?? '0';
final availableCash = asset?.availableCash ?? '0';
//
if (_priceController.text.isEmpty && currentPrice != '0') {
_priceController.text = currentPrice;
}
return Scaffold(
backgroundColor: _bgGray,
appBar: AppBar(
backgroundColor: Colors.white,
elevation: 0,
leading: IconButton(
icon: const Icon(Icons.arrow_back, color: _darkText),
onPressed: () => context.pop(),
),
title: const Text(
'发布广告',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: _darkText,
),
),
centerTitle: true,
),
body: SingleChildScrollView(
child: Column(
children: [
const SizedBox(height: 16),
//
_buildTypeSelector(),
const SizedBox(height: 16),
//
_buildBalanceCard(availableShares, availableCash),
const SizedBox(height: 16),
//
_buildPriceInput(currentPrice),
const SizedBox(height: 16),
//
_buildQuantityInput(availableShares, availableCash, currentPrice),
const SizedBox(height: 16),
//
_buildRemarkInput(),
const SizedBox(height: 16),
//
_buildEstimateCard(),
const SizedBox(height: 24),
//
_buildPublishButton(c2cState),
const SizedBox(height: 16),
//
_buildTips(),
const SizedBox(height: 24),
],
),
),
);
}
Widget _buildTypeSelector() {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Expanded(
child: GestureDetector(
onTap: () => setState(() => _selectedType = 0),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 12),
decoration: BoxDecoration(
color: _selectedType == 0 ? _green : Colors.transparent,
borderRadius: BorderRadius.circular(8),
),
child: Text(
'我要买入',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: _selectedType == 0 ? Colors.white : _grayText,
),
),
),
),
),
Expanded(
child: GestureDetector(
onTap: () => setState(() => _selectedType = 1),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 12),
decoration: BoxDecoration(
color: _selectedType == 1 ? _red : Colors.transparent,
borderRadius: BorderRadius.circular(8),
),
child: Text(
'我要卖出',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: _selectedType == 1 ? Colors.white : _grayText,
),
),
),
),
),
],
),
);
}
Widget _buildBalanceCard(String availableShares, String availableCash) {
final isBuy = _selectedType == 0;
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
isBuy ? '可用积分值' : '可用积分股',
style: const TextStyle(fontSize: 14, color: _grayText),
),
Text(
isBuy ? formatAmount(availableCash) : formatAmount(availableShares),
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: isBuy ? _green : _orange,
),
),
],
),
);
}
Widget _buildPriceInput(String currentPrice) {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'单价',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: _darkText,
),
),
Text(
'当前价: ${formatPrice(currentPrice)}',
style: const TextStyle(fontSize: 12, color: _grayText),
),
],
),
const SizedBox(height: 12),
TextField(
controller: _priceController,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
inputFormatters: [
FilteringTextInputFormatter.allow(RegExp(r'^\d+\.?\d{0,8}')),
],
decoration: InputDecoration(
hintText: '请输入单价',
hintStyle: const TextStyle(color: _grayText),
filled: true,
fillColor: _bgGray,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: _orange, width: 2),
),
suffixText: '积分值/股',
suffixStyle: const TextStyle(color: _grayText, fontSize: 14),
),
onChanged: (_) => setState(() {}),
),
],
),
);
}
Widget _buildQuantityInput(
String availableShares,
String availableCash,
String currentPrice,
) {
final isBuy = _selectedType == 0;
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'数量',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: _darkText,
),
),
const SizedBox(height: 12),
TextField(
controller: _quantityController,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
inputFormatters: [
FilteringTextInputFormatter.allow(RegExp(r'^\d+\.?\d{0,4}')),
],
decoration: InputDecoration(
hintText: '请输入数量',
hintStyle: const TextStyle(color: _grayText),
filled: true,
fillColor: _bgGray,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: _orange, width: 2),
),
suffixIcon: TextButton(
onPressed: () {
if (isBuy) {
//
final price = double.tryParse(_priceController.text) ?? 0;
final cash = double.tryParse(availableCash) ?? 0;
if (price > 0) {
_quantityController.text = (cash / price).toStringAsFixed(4);
}
} else {
//
_quantityController.text = availableShares;
}
setState(() {});
},
child: const Text(
'全部',
style: TextStyle(
color: _orange,
fontWeight: FontWeight.w500,
),
),
),
suffixText: '积分股',
suffixStyle: const TextStyle(color: _grayText, fontSize: 14),
),
onChanged: (_) => setState(() {}),
),
],
),
);
}
Widget _buildRemarkInput() {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'备注 (可选)',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: _darkText,
),
),
const SizedBox(height: 12),
TextField(
controller: _remarkController,
maxLength: 100,
maxLines: 2,
decoration: InputDecoration(
hintText: '添加交易说明,如联系方式等',
hintStyle: const TextStyle(color: _grayText),
filled: true,
fillColor: _bgGray,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: _orange, width: 2),
),
counterStyle: const TextStyle(color: _grayText),
),
),
],
),
);
}
Widget _buildEstimateCard() {
final price = double.tryParse(_priceController.text) ?? 0;
final quantity = double.tryParse(_quantityController.text) ?? 0;
final total = price * quantity;
final isBuy = _selectedType == 0;
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: _orange.withOpacity(0.05),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: _orange.withOpacity(0.2)),
),
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('交易总额', style: TextStyle(fontSize: 14, color: _grayText)),
Text(
'${formatAmount(total.toString())} 积分值',
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: _orange,
),
),
],
),
const SizedBox(height: 8),
Divider(color: _orange.withOpacity(0.2)),
const SizedBox(height: 8),
Text(
isBuy
? '发布后,其他用户可以接单卖出积分股给您'
: '发布后,其他用户可以接单用积分值购买您的积分股',
style: const TextStyle(fontSize: 12, color: _grayText),
),
],
),
);
}
Widget _buildPublishButton(C2cTradingState c2cState) {
final price = double.tryParse(_priceController.text) ?? 0;
final quantity = double.tryParse(_quantityController.text) ?? 0;
final isValid = price > 0 && quantity > 0;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: SizedBox(
width: double.infinity,
height: 50,
child: ElevatedButton(
onPressed: isValid && !c2cState.isLoading ? _handlePublish : null,
style: ElevatedButton.styleFrom(
backgroundColor: _selectedType == 0 ? _green : _red,
foregroundColor: Colors.white,
disabledBackgroundColor: (_selectedType == 0 ? _green : _red).withOpacity(0.4),
disabledForegroundColor: Colors.white70,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: c2cState.isLoading
? const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: Text(
_selectedType == 0 ? '发布买入广告' : '发布卖出广告',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
),
),
);
}
Widget _buildTips() {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: const [
Icon(Icons.info_outline, size: 16, color: _orange),
SizedBox(width: 8),
Text(
'交易说明',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: _orange,
),
),
],
),
const SizedBox(height: 8),
const Text(
'1. 发布广告后,您的资产将被冻结直到交易完成或取消\n'
'2. 其他用户接单后,需在规定时间内完成交易\n'
'3. 买方需先转账积分值,卖方确认收款后积分股自动划转\n'
'4. 如遇问题,请联系客服处理',
style: TextStyle(
fontSize: 12,
color: _grayText,
height: 1.5,
),
),
],
),
);
}
Future<void> _handlePublish() async {
final price = _priceController.text.trim();
final quantity = _quantityController.text.trim();
final remark = _remarkController.text.trim();
final type = _selectedType == 0 ? 'BUY' : 'SELL';
//
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(_selectedType == 0 ? '确认发布买入广告' : '确认发布卖出广告'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('单价: $price 积分值/股'),
const SizedBox(height: 8),
Text('数量: $quantity 积分股'),
const SizedBox(height: 8),
Text(
'总额: ${formatAmount((double.parse(price) * double.parse(quantity)).toString())} 积分值',
style: const TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
Text(
_selectedType == 0
? '发布后,您的积分值将被冻结'
: '发布后,您的积分股将被冻结',
style: TextStyle(fontSize: 12, color: _grayText),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('取消'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: Text(
'确认发布',
style: TextStyle(color: _selectedType == 0 ? _green : _red),
),
),
],
),
);
if (confirmed != true) return;
final notifier = ref.read(c2cTradingNotifierProvider.notifier);
final success = await notifier.createOrder(
type: type,
price: price,
quantity: quantity,
remark: remark.isEmpty ? null : remark,
);
if (success && mounted) {
//
ref.invalidate(c2cOrdersProvider(type));
ref.invalidate(myC2cOrdersProvider);
//
final user = ref.read(userNotifierProvider);
ref.invalidate(accountAssetProvider(user.accountSequence ?? ''));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('广告发布成功'),
backgroundColor: _green,
),
);
context.pop();
} else if (mounted) {
final error = ref.read(c2cTradingNotifierProvider).error;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('发布失败: ${error ?? '未知错误'}'),
backgroundColor: _red,
),
);
}
}
}

View File

@ -1,5 +1,3 @@
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';
@ -12,7 +10,9 @@ import '../../../domain/entities/trade_order.dart';
import '../../../domain/entities/kline.dart';
import '../../providers/user_providers.dart';
import '../../providers/trading_providers.dart';
import '../../providers/asset_providers.dart';
import '../../widgets/shimmer_loading.dart';
import '../../widgets/kline_chart/kline_chart_widget.dart';
class TradingPage extends ConsumerStatefulWidget {
const TradingPage({super.key});
@ -34,9 +34,10 @@ class _TradingPageState extends ConsumerState<TradingPage> {
//
int _selectedTab = 1; // 0: , 1:
int _selectedTimeRange = 1; //
int _selectedTimeRange = 4; // 1
final _quantityController = TextEditingController();
final _priceController = TextEditingController();
bool _isFullScreen = false; // K线图全屏状态
final List<String> _timeRanges = ['1分', '5分', '15分', '30分', '1时', '4时', ''];
@ -52,9 +53,26 @@ class _TradingPageState extends ConsumerState<TradingPage> {
final priceAsync = ref.watch(currentPriceProvider);
final marketAsync = ref.watch(marketOverviewProvider);
final ordersAsync = ref.watch(ordersProvider);
final klinesAsync = ref.watch(klinesProvider);
final user = ref.watch(userNotifierProvider);
final accountSequence = user.accountSequence ?? '';
// K线图模式
if (_isFullScreen) {
return KlineChartWidget(
klines: klinesAsync.valueOrNull ?? [],
currentPrice: priceAsync.valueOrNull?.price ?? '0',
isFullScreen: true,
onFullScreenToggle: () => setState(() => _isFullScreen = false),
timeRanges: _timeRanges,
selectedTimeIndex: _selectedTimeRange,
onTimeRangeChanged: (index) {
setState(() => _selectedTimeRange = index);
ref.read(selectedKlinePeriodProvider.notifier).state = _timeRanges[index];
},
);
}
return Scaffold(
backgroundColor: const Color(0xFFF5F5F5),
body: SafeArea(
@ -64,6 +82,7 @@ class _TradingPageState extends ConsumerState<TradingPage> {
ref.invalidate(currentPriceProvider);
ref.invalidate(marketOverviewProvider);
ref.invalidate(ordersProvider);
ref.invalidate(klinesProvider);
},
child: Column(
children: [
@ -73,7 +92,7 @@ class _TradingPageState extends ConsumerState<TradingPage> {
child: Column(
children: [
_buildPriceCard(priceAsync),
_buildChartSection(priceAsync),
_buildChartSection(priceAsync, klinesAsync),
_buildMarketDataCard(marketAsync),
_buildTradingPanel(priceAsync),
_buildMyOrdersCard(ordersAsync),
@ -181,98 +200,34 @@ class _TradingPageState extends ConsumerState<TradingPage> {
);
}
Widget _buildChartSection(AsyncValue<PriceInfo?> priceAsync) {
Widget _buildChartSection(AsyncValue<PriceInfo?> priceAsync, AsyncValue<List<Kline>> klinesAsync) {
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 SizedBox(
height: 280,
child: Center(child: CircularProgressIndicator(strokeWidth: 2)),
)
: KlineChartWidget(
klines: klines,
currentPrice: currentPrice,
isFullScreen: false,
onFullScreenToggle: () => setState(() => _isFullScreen = true),
timeRanges: _timeRanges,
selectedTimeIndex: _selectedTimeRange,
onTimeRangeChanged: (index) {
setState(() => _selectedTimeRange = index);
ref.read(selectedKlinePeriodProvider.notifier).state = _timeRanges[index];
},
),
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,
),
),
),
),
);
}),
),
),
],
),
);
}
@ -387,6 +342,17 @@ class _TradingPageState extends ConsumerState<TradingPage> {
final priceInfo = priceAsync.valueOrNull;
final currentPrice = priceInfo?.price ?? '0';
//
final user = ref.watch(userNotifierProvider);
final accountSequence = user.accountSequence ?? '';
final assetAsync = ref.watch(accountAssetProvider(accountSequence));
final asset = assetAsync.valueOrNull;
//
final availableShares = asset?.availableShares ?? '0';
//
final availableCash = asset?.availableCash ?? '0';
//
if (_priceController.text.isEmpty && priceInfo != null) {
_priceController.text = currentPrice;
@ -461,11 +427,47 @@ class _TradingPageState extends ConsumerState<TradingPage> {
),
),
const SizedBox(height: 24),
//
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: _orange.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
_selectedTab == 0 ? '可用积分值' : '可用积分股',
style: const TextStyle(fontSize: 12, color: _grayText),
),
Text(
_selectedTab == 0
? formatAmount(availableCash)
: formatAmount(availableShares),
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: _orange,
),
),
],
),
),
const SizedBox(height: 16),
//
_buildInputField('价格', _priceController, '请输入价格', '积分值'),
const SizedBox(height: 16),
//
_buildInputField('数量', _quantityController, '请输入数量', '积分股'),
// - "全部"
_buildQuantityInputField(
'数量',
_quantityController,
'请输入数量',
'积分股',
_selectedTab == 1 ? availableShares : null,
_selectedTab == 0 ? availableCash : null,
currentPrice,
),
const SizedBox(height: 16),
// /
Container(
@ -543,6 +545,94 @@ class _TradingPageState extends ConsumerState<TradingPage> {
);
}
Widget _buildQuantityInputField(
String label,
TextEditingController controller,
String hint,
String suffix,
String? availableSharesForSell,
String? availableCashForBuy,
String currentPrice,
) {
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),
),
onChanged: (_) => setState(() {}),
),
),
//
GestureDetector(
onTap: () {
if (availableSharesForSell != null) {
//
controller.text = availableSharesForSell;
} else if (availableCashForBuy != null) {
//
final price = double.tryParse(currentPrice) ?? 0;
final cash = double.tryParse(availableCashForBuy) ?? 0;
if (price > 0) {
final maxQuantity = cash / price;
controller.text = maxQuantity.toStringAsFixed(4);
}
}
setState(() {});
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
margin: const EdgeInsets.only(right: 4),
decoration: BoxDecoration(
color: _orange.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(6),
),
child: const Text(
'全部',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: _orange,
),
),
),
),
Text(suffix, style: const TextStyle(fontSize: 12, color: _grayText)),
const SizedBox(width: 12),
],
),
),
],
);
}
Widget _buildInputField(
String label,
TextEditingController controller,
@ -814,6 +904,70 @@ class _TradingPageState extends ConsumerState<TradingPage> {
}
final isBuy = _selectedTab == 0;
final price = double.tryParse(_priceController.text) ?? 0;
final quantity = double.tryParse(_quantityController.text) ?? 0;
//
if (!isBuy) {
final total = price * quantity;
final burned = total * 0.1;
final received = total * 0.9;
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('确认卖出'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('卖出数量: ${formatAmount(quantity.toString())} 积分股'),
const SizedBox(height: 8),
Text('卖出价格: ${formatPrice(price.toString())} 积分值'),
const SizedBox(height: 8),
Text('交易总额: ${formatAmount(total.toString())} 积分值'),
const SizedBox(height: 8),
Text(
'销毁金额: ${formatAmount(burned.toString())} 积分值 (10%)',
style: const TextStyle(color: _red),
),
const SizedBox(height: 8),
Text(
'实际获得: ${formatAmount(received.toString())} 积分值',
style: const TextStyle(
fontWeight: FontWeight.bold,
color: _green,
),
),
const SizedBox(height: 16),
const Text(
'注意: 卖出积分股将扣除10%进入黑洞销毁,此操作不可撤销。',
style: TextStyle(
fontSize: 12,
color: _grayText,
),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('取消'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text(
'确认卖出',
style: TextStyle(color: _orange),
),
),
],
),
);
if (confirmed != true) return;
}
bool success;
if (isBuy) {
@ -837,174 +991,14 @@ class _TradingPageState extends ConsumerState<TradingPage> {
);
if (success) {
_quantityController.clear();
//
//
ref.invalidate(ordersProvider);
ref.invalidate(currentPriceProvider);
ref.invalidate(marketOverviewProvider);
//
final user = ref.read(userNotifierProvider);
ref.invalidate(accountAssetProvider(user.accountSequence ?? ''));
}
}
}
}
// 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;
}
}

View File

@ -0,0 +1,169 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../data/datasources/remote/trading_remote_datasource.dart';
import '../../data/models/c2c_order_model.dart';
import '../../core/di/injection.dart';
/// C2C市场订单列表 Provider
final c2cOrdersProvider = FutureProvider.family<C2cOrdersPageModel, String?>(
(ref, type) async {
final dataSource = getIt<TradingRemoteDataSource>();
final result = await dataSource.getC2cOrders(type: type);
ref.keepAlive();
final timer = Timer(const Duration(minutes: 1), () {
ref.invalidateSelf();
});
ref.onDispose(() => timer.cancel());
return result;
},
);
/// C2C订单列表 Provider
final myC2cOrdersProvider = FutureProvider<C2cOrdersPageModel>(
(ref) async {
final dataSource = getIt<TradingRemoteDataSource>();
final result = await dataSource.getMyC2cOrders();
ref.keepAlive();
final timer = Timer(const Duration(minutes: 1), () {
ref.invalidateSelf();
});
ref.onDispose(() => timer.cancel());
return result;
},
);
/// C2C订单详情 Provider
final c2cOrderDetailProvider = FutureProvider.family<C2cOrderModel, String>(
(ref, orderNo) async {
final dataSource = getIt<TradingRemoteDataSource>();
return dataSource.getC2cOrderDetail(orderNo);
},
);
/// C2C交易状态
class C2cTradingState {
final bool isLoading;
final String? error;
final C2cOrderModel? lastOrder;
C2cTradingState({
this.isLoading = false,
this.error,
this.lastOrder,
});
C2cTradingState copyWith({
bool? isLoading,
String? error,
C2cOrderModel? lastOrder,
bool clearError = false,
}) {
return C2cTradingState(
isLoading: isLoading ?? this.isLoading,
error: clearError ? null : (error ?? this.error),
lastOrder: lastOrder ?? this.lastOrder,
);
}
}
class C2cTradingNotifier extends StateNotifier<C2cTradingState> {
final TradingRemoteDataSource _dataSource;
C2cTradingNotifier(this._dataSource) : super(C2cTradingState());
/// C2C订单广
Future<bool> createOrder({
required String type,
required String price,
required String quantity,
String? minAmount,
String? maxAmount,
String? remark,
}) async {
state = state.copyWith(isLoading: true, clearError: true);
try {
final order = await _dataSource.createC2cOrder(
type: type,
price: price,
quantity: quantity,
minAmount: minAmount,
maxAmount: maxAmount,
remark: remark,
);
state = state.copyWith(isLoading: false, lastOrder: order);
return true;
} catch (e) {
state = state.copyWith(isLoading: false, error: e.toString());
return false;
}
}
///
Future<bool> takeOrder(String orderNo, {String? quantity}) async {
state = state.copyWith(isLoading: true, clearError: true);
try {
final order = await _dataSource.takeC2cOrder(orderNo, quantity: quantity);
state = state.copyWith(isLoading: false, lastOrder: order);
return true;
} catch (e) {
state = state.copyWith(isLoading: false, error: e.toString());
return false;
}
}
///
Future<bool> cancelOrder(String orderNo) async {
state = state.copyWith(isLoading: true, clearError: true);
try {
await _dataSource.cancelC2cOrder(orderNo);
state = state.copyWith(isLoading: false);
return true;
} catch (e) {
state = state.copyWith(isLoading: false, error: e.toString());
return false;
}
}
///
Future<bool> confirmPayment(String orderNo) async {
state = state.copyWith(isLoading: true, clearError: true);
try {
final order = await _dataSource.confirmC2cPayment(orderNo);
state = state.copyWith(isLoading: false, lastOrder: order);
return true;
} catch (e) {
state = state.copyWith(isLoading: false, error: e.toString());
return false;
}
}
///
Future<bool> confirmReceived(String orderNo) async {
state = state.copyWith(isLoading: true, clearError: true);
try {
final order = await _dataSource.confirmC2cReceived(orderNo);
state = state.copyWith(isLoading: false, lastOrder: order);
return true;
} catch (e) {
state = state.copyWith(isLoading: false, error: e.toString());
return false;
}
}
void clearError() {
state = state.copyWith(clearError: true);
}
void clearState() {
state = C2cTradingState();
}
}
final c2cTradingNotifierProvider =
StateNotifierProvider<C2cTradingNotifier, C2cTradingState>(
(ref) => C2cTradingNotifier(getIt<TradingRemoteDataSource>()),
);

View File

@ -0,0 +1,111 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../data/datasources/remote/trading_remote_datasource.dart';
import '../../data/models/p2p_transfer_model.dart';
import '../../core/di/injection.dart';
/// P2P转账状态
class TransferState {
final bool isLoading;
final String? error;
final P2pTransferModel? lastTransfer;
final AccountLookupModel? recipientAccount;
TransferState({
this.isLoading = false,
this.error,
this.lastTransfer,
this.recipientAccount,
});
TransferState copyWith({
bool? isLoading,
String? error,
P2pTransferModel? lastTransfer,
AccountLookupModel? recipientAccount,
bool clearError = false,
bool clearRecipient = false,
}) {
return TransferState(
isLoading: isLoading ?? this.isLoading,
error: clearError ? null : (error ?? this.error),
lastTransfer: lastTransfer ?? this.lastTransfer,
recipientAccount: clearRecipient ? null : (recipientAccount ?? this.recipientAccount),
);
}
}
class TransferNotifier extends StateNotifier<TransferState> {
final TradingRemoteDataSource _dataSource;
TransferNotifier(this._dataSource) : super(TransferState());
///
Future<AccountLookupModel?> lookupRecipient(String phone) async {
state = state.copyWith(isLoading: true, clearError: true, clearRecipient: true);
try {
final account = await _dataSource.lookupAccount(phone);
state = state.copyWith(
isLoading: false,
recipientAccount: account,
);
return account;
} catch (e) {
state = state.copyWith(
isLoading: false,
error: e.toString(),
);
return null;
}
}
/// P2P转账
Future<bool> transfer({
required String toPhone,
required String amount,
String? memo,
}) async {
state = state.copyWith(isLoading: true, clearError: true);
try {
final result = await _dataSource.p2pTransfer(
toPhone: toPhone,
amount: amount,
memo: memo,
);
state = state.copyWith(
isLoading: false,
lastTransfer: result,
);
return true;
} catch (e) {
state = state.copyWith(
isLoading: false,
error: e.toString(),
);
return false;
}
}
///
void clearState() {
state = TransferState();
}
///
void clearError() {
state = state.copyWith(clearError: true);
}
}
final transferNotifierProvider =
StateNotifierProvider<TransferNotifier, TransferState>(
(ref) => TransferNotifier(getIt<TradingRemoteDataSource>()),
);
/// P2P转账历史记录
final p2pTransferHistoryProvider =
FutureProvider.family<List<P2pTransferModel>, String>(
(ref, accountSequence) async {
final dataSource = getIt<TradingRemoteDataSource>();
return dataSource.getP2pTransferHistory(accountSequence);
},
);

View File

@ -0,0 +1,691 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../../domain/entities/kline.dart';
import 'kline_painter.dart';
import 'kline_indicator_painter.dart';
import 'kline_volume_painter.dart';
import 'kline_data_processor.dart';
/// K线图主组件
class KlineChartWidget extends StatefulWidget {
final List<Kline> klines;
final String currentPrice;
final bool showVolume;
final bool isFullScreen;
final VoidCallback? onFullScreenToggle;
final List<String> timeRanges;
final int selectedTimeIndex;
final Function(int)? onTimeRangeChanged;
const KlineChartWidget({
super.key,
required this.klines,
required this.currentPrice,
this.showVolume = true,
this.isFullScreen = false,
this.onFullScreenToggle,
this.timeRanges = const ['1分', '5分', '15分', '30分', '1时', '4时', ''],
this.selectedTimeIndex = 4,
this.onTimeRangeChanged,
});
@override
State<KlineChartWidget> createState() => _KlineChartWidgetState();
}
class _KlineChartWidgetState extends State<KlineChartWidget> {
//
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 _borderGray = Color(0xFFE5E7EB);
//
double _scale = 1.0;
double _prevScale = 1.0;
double _offsetX = 0.0;
double _startOffsetX = 0.0;
Offset? _startFocalPoint;
// K线数量范围
int _visibleCandleCount = 60;
static const int _minVisibleCandles = 20;
static const int _maxVisibleCandles = 200;
//
int _selectedMainIndicator = 0; // 0: MA, 1: EMA, 2: BOLL
int _selectedSubIndicator = 0; // 0: MACD, 1: KDJ, 2: RSI
// 线
bool _showCrossLine = false;
int _crossLineIndex = -1;
@override
void initState() {
super.initState();
//
WidgetsBinding.instance.addPostFrameCallback((_) {
_scrollToEnd();
});
}
void _scrollToEnd() {
if (widget.klines.isEmpty) return;
final totalCandles = widget.klines.length;
if (totalCandles > _visibleCandleCount) {
setState(() {
_offsetX = (totalCandles - _visibleCandleCount).toDouble();
});
}
}
@override
void didUpdateWidget(KlineChartWidget oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.klines.length != widget.klines.length) {
_scrollToEnd();
}
}
@override
Widget build(BuildContext context) {
if (widget.isFullScreen) {
return _buildFullScreenChart();
}
return _buildNormalChart();
}
Widget _buildNormalChart() {
return Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
),
child: Column(
children: [
//
_buildToolbar(),
// K线图区域
_buildChartArea(height: 200),
//
_buildTimeRangeSelector(),
],
),
);
}
Widget _buildFullScreenChart() {
return Scaffold(
backgroundColor: Colors.white,
appBar: AppBar(
backgroundColor: Colors.white,
elevation: 0,
leading: IconButton(
icon: const Icon(Icons.close, color: Color(0xFF1F2937)),
onPressed: widget.onFullScreenToggle,
),
title: const Text(
'K线图',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Color(0xFF1F2937),
),
),
centerTitle: true,
actions: [
IconButton(
icon: const Icon(Icons.refresh, color: _orange),
onPressed: () {
setState(() {
_scale = 1.0;
_visibleCandleCount = 60;
_scrollToEnd();
});
},
),
],
),
body: SafeArea(
child: Column(
children: [
//
_buildIndicatorSelector(),
// K线图区域
Expanded(child: _buildChartArea()),
//
_buildTimeRangeSelector(),
const SizedBox(height: 8),
],
),
),
);
}
Widget _buildToolbar() {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 8, 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
//
Row(
children: [
_buildIndicatorChip('MA', _selectedMainIndicator == 0, () {
setState(() => _selectedMainIndicator = 0);
}),
const SizedBox(width: 8),
_buildIndicatorChip('MACD', _selectedSubIndicator == 0, () {
setState(() => _selectedSubIndicator = 0);
}),
const SizedBox(width: 8),
_buildIndicatorChip('KDJ', _selectedSubIndicator == 1, () {
setState(() => _selectedSubIndicator = 1);
}),
],
),
//
IconButton(
icon: Icon(
widget.isFullScreen ? Icons.fullscreen_exit : Icons.fullscreen,
color: _orange,
size: 24,
),
onPressed: widget.onFullScreenToggle,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
),
],
),
);
}
Widget _buildIndicatorChip(String label, bool isSelected, VoidCallback onTap) {
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: isSelected ? _orange.withOpacity(0.1) : Colors.transparent,
borderRadius: BorderRadius.circular(4),
border: Border.all(
color: isSelected ? _orange : _borderGray,
),
),
child: Text(
label,
style: TextStyle(
fontSize: 11,
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
color: isSelected ? _orange : _grayText,
),
),
),
);
}
Widget _buildIndicatorSelector() {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
border: Border(bottom: BorderSide(color: _borderGray)),
),
child: Row(
children: [
const Text('主图:', style: TextStyle(fontSize: 12, color: _grayText)),
const SizedBox(width: 8),
_buildIndicatorChip('MA', _selectedMainIndicator == 0, () {
setState(() => _selectedMainIndicator = 0);
}),
const SizedBox(width: 4),
_buildIndicatorChip('EMA', _selectedMainIndicator == 1, () {
setState(() => _selectedMainIndicator = 1);
}),
const SizedBox(width: 4),
_buildIndicatorChip('BOLL', _selectedMainIndicator == 2, () {
setState(() => _selectedMainIndicator = 2);
}),
const SizedBox(width: 16),
const Text('副图:', style: TextStyle(fontSize: 12, color: _grayText)),
const SizedBox(width: 8),
_buildIndicatorChip('MACD', _selectedSubIndicator == 0, () {
setState(() => _selectedSubIndicator = 0);
}),
const SizedBox(width: 4),
_buildIndicatorChip('KDJ', _selectedSubIndicator == 1, () {
setState(() => _selectedSubIndicator = 1);
}),
const SizedBox(width: 4),
_buildIndicatorChip('RSI', _selectedSubIndicator == 2, () {
setState(() => _selectedSubIndicator = 2);
}),
],
),
);
}
Widget _buildChartArea({double? height}) {
return GestureDetector(
onScaleStart: _onScaleStart,
onScaleUpdate: _onScaleUpdate,
onScaleEnd: _onScaleEnd,
onLongPressStart: _onLongPressStart,
onLongPressMoveUpdate: _onLongPressMoveUpdate,
onLongPressEnd: _onLongPressEnd,
child: Container(
height: height,
padding: const EdgeInsets.symmetric(horizontal: 8),
child: LayoutBuilder(
builder: (context, constraints) {
final chartHeight = height ?? constraints.maxHeight;
final chartWidth = constraints.maxWidth;
if (widget.klines.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.show_chart, size: 48, color: _grayText.withOpacity(0.5)),
const SizedBox(height: 8),
const Text('暂无K线数据', style: TextStyle(color: _grayText)),
],
),
);
}
//
final visibleData = _getVisibleData();
//
final processor = KlineDataProcessor(widget.klines);
final maData = processor.calculateMA([5, 10, 20, 60]);
final macdData = processor.calculateMACD();
final kdjData = processor.calculateKDJ();
final rsiData = processor.calculateRSI();
final bollData = processor.calculateBOLL();
final emaData = processor.calculateEMA([5, 10, 20]);
//
final mainChartHeight = widget.showVolume
? (widget.isFullScreen ? chartHeight * 0.5 : chartHeight * 0.6)
: chartHeight * 0.7;
final volumeHeight = widget.showVolume
? (widget.isFullScreen ? chartHeight * 0.15 : chartHeight * 0.2)
: 0.0;
final indicatorHeight = widget.isFullScreen
? chartHeight * 0.25
: (widget.showVolume ? chartHeight * 0.2 : chartHeight * 0.3);
return Column(
children: [
// K线 + MA/EMA/BOLL
SizedBox(
height: mainChartHeight,
child: Stack(
children: [
CustomPaint(
size: Size(chartWidth, mainChartHeight),
painter: KlinePainter(
klines: visibleData.klines,
maData: _selectedMainIndicator == 0 ? _getVisibleMAData(maData, visibleData.startIndex) : null,
emaData: _selectedMainIndicator == 1 ? _getVisibleMAData(emaData, visibleData.startIndex) : null,
bollData: _selectedMainIndicator == 2 ? _getVisibleBollData(bollData, visibleData.startIndex) : null,
crossLineIndex: _showCrossLine ? _crossLineIndex - visibleData.startIndex : -1,
),
),
// 线
if (_showCrossLine && _crossLineIndex >= 0 && _crossLineIndex < widget.klines.length)
_buildCrossLineInfo(visibleData),
//
if (visibleData.klines.isNotEmpty)
Positioned(
right: 0,
top: _calcPriceY(
visibleData,
double.tryParse(widget.currentPrice) ?? 0,
mainChartHeight,
),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
decoration: BoxDecoration(
color: _orange,
borderRadius: BorderRadius.circular(2),
),
child: Text(
_formatPrice(double.tryParse(widget.currentPrice) ?? 0),
style: const TextStyle(fontSize: 9, color: Colors.white),
),
),
),
],
),
),
//
if (widget.showVolume)
SizedBox(
height: volumeHeight,
child: CustomPaint(
size: Size(chartWidth, volumeHeight),
painter: KlineVolumePainter(
klines: visibleData.klines,
crossLineIndex: _showCrossLine ? _crossLineIndex - visibleData.startIndex : -1,
),
),
),
// MACD/KDJ/RSI
SizedBox(
height: indicatorHeight,
child: CustomPaint(
size: Size(chartWidth, indicatorHeight),
painter: KlineIndicatorPainter(
indicatorType: _selectedSubIndicator,
macdData: _selectedSubIndicator == 0 ? _getVisibleMacdData(macdData, visibleData.startIndex) : null,
kdjData: _selectedSubIndicator == 1 ? _getVisibleKdjData(kdjData, visibleData.startIndex) : null,
rsiData: _selectedSubIndicator == 2 ? _getVisibleRsiData(rsiData, visibleData.startIndex) : null,
crossLineIndex: _showCrossLine ? _crossLineIndex - visibleData.startIndex : -1,
),
),
),
],
);
},
),
),
);
}
Widget _buildTimeRangeSelector() {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: List.generate(widget.timeRanges.length, (index) {
final isSelected = widget.selectedTimeIndex == index;
return Padding(
padding: const EdgeInsets.only(right: 8),
child: GestureDetector(
onTap: () => widget.onTimeRangeChanged?.call(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(
widget.timeRanges[index],
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: isSelected ? Colors.white : _grayText,
),
),
),
),
);
}),
),
),
);
}
//
void _onScaleStart(ScaleStartDetails details) {
_prevScale = _scale;
_startOffsetX = _offsetX;
_startFocalPoint = details.focalPoint;
}
void _onScaleUpdate(ScaleUpdateDetails details) {
setState(() {
//
if (details.scale != 1.0) {
final newScale = (_prevScale * details.scale).clamp(0.5, 3.0);
_scale = newScale;
// K线数量
_visibleCandleCount = (60 / _scale).round().clamp(_minVisibleCandles, _maxVisibleCandles);
}
//
if (_startFocalPoint != null) {
final dx = details.focalPoint.dx - _startFocalPoint!.dx;
// K线
final candleShift = -dx / 10;
_offsetX = (_startOffsetX + candleShift).clamp(
0.0,
math.max(0.0, (widget.klines.length - _visibleCandleCount).toDouble()),
);
}
});
}
void _onScaleEnd(ScaleEndDetails details) {
_startFocalPoint = null;
}
void _onLongPressStart(LongPressStartDetails details) {
_updateCrossLine(details.localPosition);
HapticFeedback.mediumImpact();
}
void _onLongPressMoveUpdate(LongPressMoveUpdateDetails details) {
_updateCrossLine(details.localPosition);
}
void _onLongPressEnd(LongPressEndDetails details) {
setState(() {
_showCrossLine = false;
_crossLineIndex = -1;
});
}
void _updateCrossLine(Offset position) {
if (widget.klines.isEmpty) return;
setState(() {
_showCrossLine = true;
// K线索引
final visibleData = _getVisibleData();
if (visibleData.klines.isEmpty) return;
final candleWidth = (MediaQuery.of(context).size.width - 32) / visibleData.klines.length;
final localIndex = (position.dx / candleWidth).floor().clamp(0, visibleData.klines.length - 1);
_crossLineIndex = visibleData.startIndex + localIndex;
});
}
//
_VisibleData _getVisibleData() {
if (widget.klines.isEmpty) {
return _VisibleData(klines: [], startIndex: 0);
}
final int maxStart = math.max(0, widget.klines.length - _visibleCandleCount);
final int startIndex = _offsetX.floor().clamp(0, maxStart);
final int endIndex = math.min(startIndex + _visibleCandleCount, widget.klines.length);
return _VisibleData(
klines: widget.klines.sublist(startIndex, endIndex),
startIndex: startIndex,
);
}
Map<int, List<double?>> _getVisibleMAData(Map<int, List<double?>> fullData, int startIndex) {
final result = <int, List<double?>>{};
for (final entry in fullData.entries) {
final endIndex = math.min(startIndex + _visibleCandleCount, entry.value.length);
if (startIndex < entry.value.length) {
result[entry.key] = entry.value.sublist(startIndex, endIndex);
}
}
return result;
}
Map<String, List<double?>> _getVisibleBollData(Map<String, List<double?>> fullData, int startIndex) {
final result = <String, List<double?>>{};
for (final entry in fullData.entries) {
final endIndex = math.min(startIndex + _visibleCandleCount, entry.value.length);
if (startIndex < entry.value.length) {
result[entry.key] = entry.value.sublist(startIndex, endIndex);
}
}
return result;
}
Map<String, List<double?>> _getVisibleMacdData(Map<String, List<double?>> fullData, int startIndex) {
final result = <String, List<double?>>{};
for (final entry in fullData.entries) {
final endIndex = math.min(startIndex + _visibleCandleCount, entry.value.length);
if (startIndex < entry.value.length) {
result[entry.key] = entry.value.sublist(startIndex, endIndex);
}
}
return result;
}
Map<String, List<double?>> _getVisibleKdjData(Map<String, List<double?>> fullData, int startIndex) {
final result = <String, List<double?>>{};
for (final entry in fullData.entries) {
final endIndex = math.min(startIndex + _visibleCandleCount, entry.value.length);
if (startIndex < entry.value.length) {
result[entry.key] = entry.value.sublist(startIndex, endIndex);
}
}
return result;
}
List<double?> _getVisibleRsiData(List<double?> fullData, int startIndex) {
final endIndex = math.min(startIndex + _visibleCandleCount, fullData.length);
if (startIndex < fullData.length) {
return fullData.sublist(startIndex, endIndex);
}
return [];
}
Widget _buildCrossLineInfo(_VisibleData visibleData) {
if (_crossLineIndex < 0 || _crossLineIndex >= widget.klines.length) {
return const SizedBox.shrink();
}
final kline = widget.klines[_crossLineIndex];
final open = double.tryParse(kline.open) ?? 0;
final high = double.tryParse(kline.high) ?? 0;
final low = double.tryParse(kline.low) ?? 0;
final close = double.tryParse(kline.close) ?? 0;
final volume = double.tryParse(kline.volume) ?? 0;
final isUp = close >= open;
return Positioned(
left: 8,
top: 8,
child: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.95),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: _borderGray),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 4,
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
_formatDateTime(kline.time),
style: const TextStyle(fontSize: 10, color: _grayText),
),
const SizedBox(height: 4),
Row(
mainAxisSize: MainAxisSize.min,
children: [
_buildInfoItem('', _formatPrice(open), isUp ? _green : _red),
const SizedBox(width: 12),
_buildInfoItem('', _formatPrice(high), _red),
],
),
const SizedBox(height: 2),
Row(
mainAxisSize: MainAxisSize.min,
children: [
_buildInfoItem('', _formatPrice(low), _green),
const SizedBox(width: 12),
_buildInfoItem('', _formatPrice(close), isUp ? _green : _red),
],
),
const SizedBox(height: 2),
_buildInfoItem('', _formatVolume(volume), _grayText),
],
),
),
);
}
Widget _buildInfoItem(String label, String value, Color valueColor) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'$label: ',
style: const TextStyle(fontSize: 10, color: _grayText),
),
Text(
value,
style: TextStyle(fontSize: 10, fontWeight: FontWeight.w500, color: valueColor),
),
],
);
}
String _formatPrice(double price) {
if (price >= 1) return price.toStringAsFixed(4);
if (price >= 0.0001) return price.toStringAsFixed(6);
return price.toStringAsExponential(2);
}
String _formatVolume(double volume) {
if (volume >= 1000000) return '${(volume / 1000000).toStringAsFixed(2)}M';
if (volume >= 1000) return '${(volume / 1000).toStringAsFixed(2)}K';
return volume.toStringAsFixed(2);
}
String _formatDateTime(DateTime time) {
return '${time.month}/${time.day} ${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}';
}
double _calcPriceY(_VisibleData visibleData, double price, double chartHeight) {
if (visibleData.klines.isEmpty) return chartHeight / 2;
double minPrice = double.infinity;
double maxPrice = double.negativeInfinity;
for (final kline in visibleData.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;
}
final range = maxPrice - minPrice;
final padding = range * 0.1;
minPrice -= padding;
maxPrice += padding;
final y = 10 + ((maxPrice - price) / (maxPrice - minPrice)) * (chartHeight - 20);
return y.clamp(0.0, chartHeight - 20);
}
}
class _VisibleData {
final List<Kline> klines;
final int startIndex;
_VisibleData({required this.klines, required this.startIndex});
}

View File

@ -0,0 +1,299 @@
import 'dart:math' as math;
import '../../../domain/entities/kline.dart';
/// K线数据处理器 -
class KlineDataProcessor {
final List<Kline> klines;
KlineDataProcessor(this.klines);
/// MA线
/// periods: [5, 10, 20, 60]
Map<int, List<double?>> calculateMA(List<int> periods) {
final result = <int, List<double?>>{};
for (final period in periods) {
final maList = <double?>[];
for (int i = 0; i < klines.length; i++) {
if (i < period - 1) {
maList.add(null);
} else {
double sum = 0;
for (int j = i - period + 1; j <= i; j++) {
sum += double.tryParse(klines[j].close) ?? 0;
}
maList.add(sum / period);
}
}
result[period] = maList;
}
return result;
}
/// EMA线
Map<int, List<double?>> calculateEMA(List<int> periods) {
final result = <int, List<double?>>{};
for (final period in periods) {
final emaList = <double?>[];
final multiplier = 2.0 / (period + 1);
double? prevEma;
for (int i = 0; i < klines.length; i++) {
final close = double.tryParse(klines[i].close) ?? 0;
if (i < period - 1) {
emaList.add(null);
} else if (i == period - 1) {
// EMA使用SMA
double sum = 0;
for (int j = 0; j <= i; j++) {
sum += double.tryParse(klines[j].close) ?? 0;
}
prevEma = sum / period;
emaList.add(prevEma);
} else {
// EMA = (Close - EMA(prev)) * multiplier + EMA(prev)
prevEma = (close - prevEma!) * multiplier + prevEma;
emaList.add(prevEma);
}
}
result[period] = emaList;
}
return result;
}
/// BOLL
/// 202
Map<String, List<double?>> calculateBOLL({int period = 20, double stdDev = 2}) {
final middle = <double?>[];
final upper = <double?>[];
final lower = <double?>[];
for (int i = 0; i < klines.length; i++) {
if (i < period - 1) {
middle.add(null);
upper.add(null);
lower.add(null);
} else {
// SMA
double sum = 0;
for (int j = i - period + 1; j <= i; j++) {
sum += double.tryParse(klines[j].close) ?? 0;
}
final sma = sum / period;
//
double variance = 0;
for (int j = i - period + 1; j <= i; j++) {
final close = double.tryParse(klines[j].close) ?? 0;
variance += math.pow(close - sma, 2);
}
final std = math.sqrt(variance / period);
middle.add(sma);
upper.add(sma + stdDev * std);
lower.add(sma - stdDev * std);
}
}
return {
'middle': middle,
'upper': upper,
'lower': lower,
};
}
/// MACD
/// 线12线26线9
Map<String, List<double?>> calculateMACD({
int fastPeriod = 12,
int slowPeriod = 26,
int signalPeriod = 9,
}) {
final dif = <double?>[];
final dea = <double?>[];
final macd = <double?>[];
// 线线EMA
final fastEma = _calculateSingleEMA(fastPeriod);
final slowEma = _calculateSingleEMA(slowPeriod);
// DIF
for (int i = 0; i < klines.length; i++) {
if (fastEma[i] == null || slowEma[i] == null) {
dif.add(null);
} else {
dif.add(fastEma[i]! - slowEma[i]!);
}
}
// DEADIF的EMA
final multiplier = 2.0 / (signalPeriod + 1);
double? prevDea;
for (int i = 0; i < dif.length; i++) {
if (dif[i] == null) {
dea.add(null);
} else if (prevDea == null) {
// DIF作为初始DEA
prevDea = dif[i];
dea.add(prevDea);
} else {
prevDea = (dif[i]! - prevDea) * multiplier + prevDea;
dea.add(prevDea);
}
}
// MACD柱
for (int i = 0; i < dif.length; i++) {
if (dif[i] == null || dea[i] == null) {
macd.add(null);
} else {
macd.add((dif[i]! - dea[i]!) * 2);
}
}
return {
'dif': dif,
'dea': dea,
'macd': macd,
};
}
List<double?> _calculateSingleEMA(int period) {
final emaList = <double?>[];
final multiplier = 2.0 / (period + 1);
double? prevEma;
for (int i = 0; i < klines.length; i++) {
final close = double.tryParse(klines[i].close) ?? 0;
if (i < period - 1) {
emaList.add(null);
} else if (i == period - 1) {
double sum = 0;
for (int j = 0; j <= i; j++) {
sum += double.tryParse(klines[j].close) ?? 0;
}
prevEma = sum / period;
emaList.add(prevEma);
} else {
prevEma = (close - prevEma!) * multiplier + prevEma;
emaList.add(prevEma);
}
}
return emaList;
}
/// KDJ
/// 9K平滑3D平滑3
Map<String, List<double?>> calculateKDJ({
int period = 9,
int kSmooth = 3,
int dSmooth = 3,
}) {
final k = <double?>[];
final d = <double?>[];
final j = <double?>[];
double? prevK = 50;
double? prevD = 50;
for (int i = 0; i < klines.length; i++) {
if (i < period - 1) {
k.add(null);
d.add(null);
j.add(null);
} else {
//
double highest = double.negativeInfinity;
double lowest = double.infinity;
for (int j = i - period + 1; j <= i; j++) {
final high = double.tryParse(klines[j].high) ?? 0;
final low = double.tryParse(klines[j].low) ?? 0;
if (high > highest) highest = high;
if (low < lowest) lowest = low;
}
final close = double.tryParse(klines[i].close) ?? 0;
// RSV = (Close - Lowest) / (Highest - Lowest) * 100
double rsv = 50;
if (highest != lowest) {
rsv = (close - lowest) / (highest - lowest) * 100;
}
// K = 2/3 * prevK + 1/3 * RSV
final currentK = (2 * prevK! + rsv) / 3;
// D = 2/3 * prevD + 1/3 * K
final currentD = (2 * prevD! + currentK) / 3;
// J = 3 * K - 2 * D
final currentJ = 3 * currentK - 2 * currentD;
k.add(currentK);
d.add(currentD);
j.add(currentJ);
prevK = currentK;
prevD = currentD;
}
}
return {
'k': k,
'd': d,
'j': j,
};
}
/// RSI
/// 14
List<double?> calculateRSI({int period = 14}) {
final rsiList = <double?>[];
double? prevAvgGain;
double? prevAvgLoss;
for (int i = 0; i < klines.length; i++) {
if (i < period) {
rsiList.add(null);
} else if (i == period) {
//
double totalGain = 0;
double totalLoss = 0;
for (int j = 1; j <= period; j++) {
final change = (double.tryParse(klines[j].close) ?? 0) -
(double.tryParse(klines[j - 1].close) ?? 0);
if (change > 0) {
totalGain += change;
} else {
totalLoss += -change;
}
}
prevAvgGain = totalGain / period;
prevAvgLoss = totalLoss / period;
final rs = prevAvgLoss == 0 ? 100 : prevAvgGain / prevAvgLoss;
final rsi = 100 - (100 / (1 + rs));
rsiList.add(rsi);
} else {
final change = (double.tryParse(klines[i].close) ?? 0) -
(double.tryParse(klines[i - 1].close) ?? 0);
final gain = change > 0 ? change : 0.0;
final loss = change < 0 ? -change : 0.0;
prevAvgGain = (prevAvgGain! * (period - 1) + gain) / period;
prevAvgLoss = (prevAvgLoss! * (period - 1) + loss) / period;
final rs = prevAvgLoss == 0 ? 100 : prevAvgGain / prevAvgLoss;
final rsi = 100 - (100 / (1 + rs));
rsiList.add(rsi);
}
}
return rsiList;
}
}

View File

@ -0,0 +1,502 @@
import 'dart:math' as math;
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
/// MACD/KDJ/RSI
class KlineIndicatorPainter extends CustomPainter {
final int indicatorType; // 0: MACD, 1: KDJ, 2: RSI
final Map<String, List<double?>>? macdData;
final Map<String, List<double?>>? kdjData;
final List<double?>? rsiData;
final int crossLineIndex;
static const Color _green = Color(0xFF10B981);
static const Color _red = Color(0xFFEF4444);
static const Color _gridColor = Color(0xFFE5E7EB);
static const Color _textColor = Color(0xFF6B7280);
static const Color _zeroLineColor = Color(0xFF9CA3AF);
// MACD颜色
static const Color _difColor = Color(0xFFFF9800);
static const Color _deaColor = Color(0xFF2196F3);
// KDJ颜色
static const Color _kColor = Color(0xFFFF9800);
static const Color _dColor = Color(0xFF2196F3);
static const Color _jColor = Color(0xFF9C27B0);
// RSI颜色
static const Color _rsiColor = Color(0xFFFF9800);
KlineIndicatorPainter({
required this.indicatorType,
this.macdData,
this.kdjData,
this.rsiData,
this.crossLineIndex = -1,
});
@override
void paint(Canvas canvas, Size size) {
const leftPadding = 8.0;
const rightPadding = 50.0;
const topPadding = 16.0;
const bottomPadding = 4.0;
final chartWidth = size.width - leftPadding - rightPadding;
final chartHeight = size.height - topPadding - bottomPadding;
// 线
final gridPaint = Paint()
..color = _gridColor
..strokeWidth = 0.5;
canvas.drawLine(
Offset(leftPadding, 0),
Offset(size.width - rightPadding, 0),
gridPaint,
);
switch (indicatorType) {
case 0:
_drawMACD(canvas, size, leftPadding, rightPadding, topPadding, bottomPadding, chartWidth, chartHeight);
break;
case 1:
_drawKDJ(canvas, size, leftPadding, rightPadding, topPadding, bottomPadding, chartWidth, chartHeight);
break;
case 2:
_drawRSI(canvas, size, leftPadding, rightPadding, topPadding, bottomPadding, chartWidth, chartHeight);
break;
}
// 线
if (crossLineIndex >= 0) {
final dataLength = _getDataLength();
if (dataLength > 0 && crossLineIndex < dataLength) {
final candleWidth = chartWidth / dataLength;
final x = leftPadding + crossLineIndex * candleWidth + candleWidth / 2;
canvas.drawLine(
Offset(x, 0),
Offset(x, size.height),
Paint()
..color = const Color(0xFF9CA3AF)
..strokeWidth = 0.5,
);
}
}
}
int _getDataLength() {
if (macdData != null && macdData!['dif'] != null) return macdData!['dif']!.length;
if (kdjData != null && kdjData!['k'] != null) return kdjData!['k']!.length;
if (rsiData != null) return rsiData!.length;
return 0;
}
void _drawMACD(
Canvas canvas,
Size size,
double leftPadding,
double rightPadding,
double topPadding,
double bottomPadding,
double chartWidth,
double chartHeight,
) {
if (macdData == null) return;
final dif = macdData!['dif'] ?? [];
final dea = macdData!['dea'] ?? [];
final macd = macdData!['macd'] ?? [];
if (dif.isEmpty) return;
//
double minValue = double.infinity;
double maxValue = double.negativeInfinity;
for (final list in [dif, dea, macd]) {
for (final v in list) {
if (v != null) {
if (v < minValue) minValue = v;
if (v > maxValue) maxValue = v;
}
}
}
// 线
final absMax = math.max(maxValue.abs(), minValue.abs());
minValue = -absMax * 1.1;
maxValue = absMax * 1.1;
final range = maxValue - minValue;
if (range == 0) return;
double valueToY(double value) {
return topPadding + ((maxValue - value) / range) * chartHeight;
}
// 线
final zeroY = valueToY(0);
canvas.drawLine(
Offset(leftPadding, zeroY),
Offset(size.width - rightPadding, zeroY),
Paint()
..color = _zeroLineColor
..strokeWidth = 0.5,
);
// MACD柱状图
final candleWidth = chartWidth / macd.length;
final barWidth = math.max(candleWidth * 0.6, 2.0);
for (int i = 0; i < macd.length; i++) {
if (macd[i] == null) continue;
final value = macd[i]!;
final x = leftPadding + i * candleWidth + candleWidth / 2;
final y = valueToY(value);
final color = value >= 0 ? _red : _green;
final alpha = (crossLineIndex >= 0 && crossLineIndex != i) ? 0.4 : 1.0;
if (value >= 0) {
canvas.drawRect(
Rect.fromLTRB(x - barWidth / 2, y, x + barWidth / 2, zeroY),
Paint()..color = color.withOpacity(alpha),
);
} else {
canvas.drawRect(
Rect.fromLTRB(x - barWidth / 2, zeroY, x + barWidth / 2, y),
Paint()..color = color.withOpacity(alpha),
);
}
}
// DIF线
_drawIndicatorLine(canvas, dif, _difColor, leftPadding, candleWidth, valueToY);
// DEA线
_drawIndicatorLine(canvas, dea, _deaColor, leftPadding, candleWidth, valueToY);
//
_drawMACDLegend(canvas, size, leftPadding, dif, dea, macd);
}
void _drawKDJ(
Canvas canvas,
Size size,
double leftPadding,
double rightPadding,
double topPadding,
double bottomPadding,
double chartWidth,
double chartHeight,
) {
if (kdjData == null) return;
final k = kdjData!['k'] ?? [];
final d = kdjData!['d'] ?? [];
final j = kdjData!['j'] ?? [];
if (k.isEmpty) return;
// KDJ的范围通常是0-100J可能超出
double minValue = 0;
double maxValue = 100;
for (final v in j) {
if (v != null) {
if (v < minValue) minValue = v;
if (v > maxValue) maxValue = v;
}
}
final range = maxValue - minValue;
if (range == 0) return;
double valueToY(double value) {
return topPadding + ((maxValue - value) / range) * chartHeight;
}
// 线205080
final refLinePaint = Paint()
..color = _gridColor
..strokeWidth = 0.5;
for (final level in [20.0, 50.0, 80.0]) {
if (level >= minValue && level <= maxValue) {
final y = valueToY(level);
canvas.drawLine(
Offset(leftPadding, y),
Offset(size.width - rightPadding, y),
refLinePaint,
);
}
}
final candleWidth = chartWidth / k.length;
// K线
_drawIndicatorLine(canvas, k, _kColor, leftPadding, candleWidth, valueToY);
// D线
_drawIndicatorLine(canvas, d, _dColor, leftPadding, candleWidth, valueToY);
// J线
_drawIndicatorLine(canvas, j, _jColor, leftPadding, candleWidth, valueToY);
//
_drawKDJLegend(canvas, size, leftPadding, k, d, j);
}
void _drawRSI(
Canvas canvas,
Size size,
double leftPadding,
double rightPadding,
double topPadding,
double bottomPadding,
double chartWidth,
double chartHeight,
) {
if (rsiData == null || rsiData!.isEmpty) return;
// RSI范围是0-100
const minValue = 0.0;
const maxValue = 100.0;
const range = maxValue - minValue;
double valueToY(double value) {
return topPadding + ((maxValue - value) / range) * chartHeight;
}
// 线305070
final refLinePaint = Paint()
..color = _gridColor
..strokeWidth = 0.5;
for (final level in [30.0, 50.0, 70.0]) {
final y = valueToY(level);
canvas.drawLine(
Offset(leftPadding, y),
Offset(size.width - rightPadding, y),
refLinePaint,
);
}
//
final overboughtPaint = Paint()
..color = _red.withOpacity(0.1)
..style = PaintingStyle.fill;
canvas.drawRect(
Rect.fromLTRB(leftPadding, valueToY(100), size.width - rightPadding, valueToY(70)),
overboughtPaint,
);
final oversoldPaint = Paint()
..color = _green.withOpacity(0.1)
..style = PaintingStyle.fill;
canvas.drawRect(
Rect.fromLTRB(leftPadding, valueToY(30), size.width - rightPadding, valueToY(0)),
oversoldPaint,
);
final candleWidth = chartWidth / rsiData!.length;
// RSI线
_drawIndicatorLine(canvas, rsiData!, _rsiColor, leftPadding, candleWidth, valueToY);
//
_drawRSILegend(canvas, size, leftPadding, rsiData!);
}
void _drawIndicatorLine(
Canvas canvas,
List<double?> data,
Color color,
double leftPadding,
double candleWidth,
double Function(double) valueToY,
) {
final paint = Paint()
..color = color
..strokeWidth = 1
..style = PaintingStyle.stroke;
final path = Path();
bool started = false;
for (int i = 0; i < data.length; i++) {
if (data[i] != null) {
final x = leftPadding + i * candleWidth + candleWidth / 2;
final y = valueToY(data[i]!);
if (!started) {
path.moveTo(x, y);
started = true;
} else {
path.lineTo(x, y);
}
}
}
canvas.drawPath(path, paint);
}
void _drawMACDLegend(
Canvas canvas,
Size size,
double leftPadding,
List<double?> dif,
List<double?> dea,
List<double?> macd,
) {
final textPainter = TextPainter(textDirection: ui.TextDirection.ltr);
double x = leftPadding;
const y = 2.0;
// 线
int index = crossLineIndex >= 0 && crossLineIndex < dif.length ? crossLineIndex : dif.length - 1;
final difValue = index >= 0 && index < dif.length ? dif[index] : null;
final deaValue = index >= 0 && index < dea.length ? dea[index] : null;
final macdValue = index >= 0 && index < macd.length ? macd[index] : null;
textPainter.text = TextSpan(
text: 'MACD(12,26,9)',
style: const TextStyle(color: _textColor, fontSize: 9),
);
textPainter.layout();
textPainter.paint(canvas, Offset(x, y));
x += textPainter.width + 8;
if (difValue != null) {
textPainter.text = TextSpan(
text: 'DIF:${difValue.toStringAsFixed(6)}',
style: const TextStyle(color: _difColor, fontSize: 9),
);
textPainter.layout();
textPainter.paint(canvas, Offset(x, y));
x += textPainter.width + 8;
}
if (deaValue != null) {
textPainter.text = TextSpan(
text: 'DEA:${deaValue.toStringAsFixed(6)}',
style: const TextStyle(color: _deaColor, fontSize: 9),
);
textPainter.layout();
textPainter.paint(canvas, Offset(x, y));
x += textPainter.width + 8;
}
if (macdValue != null) {
textPainter.text = TextSpan(
text: 'MACD:${macdValue.toStringAsFixed(6)}',
style: TextStyle(color: macdValue >= 0 ? _red : _green, fontSize: 9),
);
textPainter.layout();
textPainter.paint(canvas, Offset(x, y));
}
}
void _drawKDJLegend(
Canvas canvas,
Size size,
double leftPadding,
List<double?> k,
List<double?> d,
List<double?> j,
) {
final textPainter = TextPainter(textDirection: ui.TextDirection.ltr);
double x = leftPadding;
const y = 2.0;
int index = crossLineIndex >= 0 && crossLineIndex < k.length ? crossLineIndex : k.length - 1;
final kValue = index >= 0 && index < k.length ? k[index] : null;
final dValue = index >= 0 && index < d.length ? d[index] : null;
final jValue = index >= 0 && index < j.length ? j[index] : null;
textPainter.text = TextSpan(
text: 'KDJ(9,3,3)',
style: const TextStyle(color: _textColor, fontSize: 9),
);
textPainter.layout();
textPainter.paint(canvas, Offset(x, y));
x += textPainter.width + 8;
if (kValue != null) {
textPainter.text = TextSpan(
text: 'K:${kValue.toStringAsFixed(2)}',
style: const TextStyle(color: _kColor, fontSize: 9),
);
textPainter.layout();
textPainter.paint(canvas, Offset(x, y));
x += textPainter.width + 8;
}
if (dValue != null) {
textPainter.text = TextSpan(
text: 'D:${dValue.toStringAsFixed(2)}',
style: const TextStyle(color: _dColor, fontSize: 9),
);
textPainter.layout();
textPainter.paint(canvas, Offset(x, y));
x += textPainter.width + 8;
}
if (jValue != null) {
textPainter.text = TextSpan(
text: 'J:${jValue.toStringAsFixed(2)}',
style: const TextStyle(color: _jColor, fontSize: 9),
);
textPainter.layout();
textPainter.paint(canvas, Offset(x, y));
}
}
void _drawRSILegend(
Canvas canvas,
Size size,
double leftPadding,
List<double?> rsi,
) {
final textPainter = TextPainter(textDirection: ui.TextDirection.ltr);
double x = leftPadding;
const y = 2.0;
int index = crossLineIndex >= 0 && crossLineIndex < rsi.length ? crossLineIndex : rsi.length - 1;
final rsiValue = index >= 0 && index < rsi.length ? rsi[index] : null;
textPainter.text = TextSpan(
text: 'RSI(14)',
style: const TextStyle(color: _textColor, fontSize: 9),
);
textPainter.layout();
textPainter.paint(canvas, Offset(x, y));
x += textPainter.width + 8;
if (rsiValue != null) {
Color color = _rsiColor;
if (rsiValue >= 70) color = _red;
if (rsiValue <= 30) color = _green;
textPainter.text = TextSpan(
text: 'RSI:${rsiValue.toStringAsFixed(2)}',
style: TextStyle(color: color, fontSize: 9),
);
textPainter.layout();
textPainter.paint(canvas, Offset(x, y));
}
}
@override
bool shouldRepaint(covariant KlineIndicatorPainter oldDelegate) {
return oldDelegate.indicatorType != indicatorType ||
oldDelegate.macdData != macdData ||
oldDelegate.kdjData != kdjData ||
oldDelegate.rsiData != rsiData ||
oldDelegate.crossLineIndex != crossLineIndex;
}
}

View File

@ -0,0 +1,437 @@
import 'dart:math' as math;
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import '../../../domain/entities/kline.dart';
/// K线主图绘制器
class KlinePainter extends CustomPainter {
final List<Kline> klines;
final Map<int, List<double?>>? maData;
final Map<int, List<double?>>? emaData;
final Map<String, List<double?>>? bollData;
final int crossLineIndex;
static const Color _green = Color(0xFF10B981);
static const Color _red = Color(0xFFEF4444);
static const Color _gridColor = Color(0xFFE5E7EB);
static const Color _textColor = Color(0xFF6B7280);
static const Color _crossLineColor = Color(0xFF9CA3AF);
// MA线颜色
static const List<Color> _maColors = [
Color(0xFFFF9800), // MA5
Color(0xFF2196F3), // MA10
Color(0xFF9C27B0), // MA20
Color(0xFF4CAF50), // MA60 绿
];
// BOLL颜色
static const Color _bollMiddleColor = Color(0xFF2196F3);
static const Color _bollUpperColor = Color(0xFFFF5722);
static const Color _bollLowerColor = Color(0xFF4CAF50);
KlinePainter({
required this.klines,
this.maData,
this.emaData,
this.bollData,
this.crossLineIndex = -1,
});
@override
void paint(Canvas canvas, Size size) {
if (klines.isEmpty) return;
//
const leftPadding = 8.0;
const rightPadding = 50.0;
const topPadding = 20.0;
const bottomPadding = 8.0;
final chartWidth = size.width - leftPadding - rightPadding;
final chartHeight = size.height - topPadding - bottomPadding;
//
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;
}
// MA/EMA/BOLL线的范围
if (maData != null) {
for (final values in maData!.values) {
for (final v in values) {
if (v != null) {
if (v < minPrice) minPrice = v;
if (v > maxPrice) maxPrice = v;
}
}
}
}
if (emaData != null) {
for (final values in emaData!.values) {
for (final v in values) {
if (v != null) {
if (v < minPrice) minPrice = v;
if (v > maxPrice) maxPrice = v;
}
}
}
}
if (bollData != null) {
for (final values in bollData!.values) {
for (final v in values) {
if (v != null) {
if (v < minPrice) minPrice = v;
if (v > maxPrice) maxPrice = v;
}
}
}
}
//
final priceRange = maxPrice - minPrice;
final padding = priceRange * 0.1;
minPrice -= padding;
maxPrice += padding;
final adjustedRange = maxPrice - minPrice;
// Y坐标转换函数
double priceToY(double price) {
return topPadding + ((maxPrice - price) / adjustedRange) * chartHeight;
}
//
_drawGrid(canvas, size, minPrice, maxPrice, leftPadding, rightPadding, topPadding, chartHeight);
// K线宽度
final candleWidth = chartWidth / klines.length;
final bodyWidth = math.max(candleWidth * 0.7, 2.0);
final gap = candleWidth * 0.15;
// 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 isUp = close >= open;
final color = isUp ? _green : _red;
final paint = Paint()..color = color;
final x = leftPadding + i * candleWidth + candleWidth / 2;
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);
final actualBodyBottom = bodyBottom - bodyTop < 1 ? bodyTop + 1 : bodyBottom;
if (isUp) {
// 线
canvas.drawRect(
Rect.fromLTRB(x - bodyWidth / 2, bodyTop, x + bodyWidth / 2, actualBodyBottom),
paint..style = PaintingStyle.fill,
);
} else {
// 线
canvas.drawRect(
Rect.fromLTRB(x - bodyWidth / 2, bodyTop, x + bodyWidth / 2, actualBodyBottom),
paint..style = PaintingStyle.fill,
);
}
}
// MA线
if (maData != null) {
int colorIndex = 0;
for (final entry in maData!.entries) {
_drawLine(
canvas,
entry.value,
_maColors[colorIndex % _maColors.length],
leftPadding,
candleWidth,
priceToY,
);
colorIndex++;
}
}
// EMA线
if (emaData != null) {
int colorIndex = 0;
for (final entry in emaData!.entries) {
_drawLine(
canvas,
entry.value,
_maColors[colorIndex % _maColors.length],
leftPadding,
candleWidth,
priceToY,
);
colorIndex++;
}
}
// BOLL线
if (bollData != null) {
_drawLine(canvas, bollData!['middle']!, _bollMiddleColor, leftPadding, candleWidth, priceToY);
_drawLine(canvas, bollData!['upper']!, _bollUpperColor, leftPadding, candleWidth, priceToY, isDashed: true);
_drawLine(canvas, bollData!['lower']!, _bollLowerColor, leftPadding, candleWidth, priceToY, isDashed: true);
}
// 线
if (crossLineIndex >= 0 && crossLineIndex < klines.length) {
_drawCrossLine(canvas, size, crossLineIndex, leftPadding, candleWidth, priceToY);
}
// MA图例
_drawLegend(canvas, size, leftPadding, topPadding);
}
void _drawGrid(
Canvas canvas,
Size size,
double minPrice,
double maxPrice,
double leftPadding,
double rightPadding,
double topPadding,
double chartHeight,
) {
final gridPaint = Paint()
..color = _gridColor
..strokeWidth = 0.5;
final textPainter = TextPainter(textDirection: ui.TextDirection.ltr);
// 线
const gridLines = 4;
final priceStep = (maxPrice - minPrice) / gridLines;
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 - priceStep * i;
textPainter.text = TextSpan(
text: _formatPrice(price),
style: const TextStyle(color: _textColor, fontSize: 9),
);
textPainter.layout();
textPainter.paint(canvas, Offset(size.width - rightPadding + 4, y - textPainter.height / 2));
}
}
void _drawLine(
Canvas canvas,
List<double?> data,
Color color,
double leftPadding,
double candleWidth,
double Function(double) priceToY, {
bool isDashed = false,
}) {
final paint = Paint()
..color = color
..strokeWidth = 1
..style = PaintingStyle.stroke;
final path = Path();
bool started = false;
for (int i = 0; i < data.length; i++) {
if (data[i] != null) {
final x = leftPadding + i * candleWidth + candleWidth / 2;
final y = priceToY(data[i]!);
if (!started) {
path.moveTo(x, y);
started = true;
} else {
path.lineTo(x, y);
}
}
}
if (isDashed) {
// 线
final dashPath = _createDashedPath(path, 4, 2);
canvas.drawPath(dashPath, paint);
} else {
canvas.drawPath(path, paint);
}
}
Path _createDashedPath(Path source, double dashLength, double gapLength) {
final path = Path();
for (final metric in source.computeMetrics()) {
double distance = 0.0;
while (distance < metric.length) {
final next = distance + dashLength;
path.addPath(
metric.extractPath(distance, math.min(next, metric.length)),
Offset.zero,
);
distance = next + gapLength;
}
}
return path;
}
void _drawCrossLine(
Canvas canvas,
Size size,
int index,
double leftPadding,
double candleWidth,
double Function(double) priceToY,
) {
final paint = Paint()
..color = _crossLineColor
..strokeWidth = 0.5;
final x = leftPadding + index * candleWidth + candleWidth / 2;
final kline = klines[index];
final close = double.tryParse(kline.close) ?? 0;
final y = priceToY(close);
// 线
canvas.drawLine(
Offset(x, 0),
Offset(x, size.height),
paint,
);
// 线
canvas.drawLine(
Offset(0, y),
Offset(size.width, y),
paint,
);
//
final textPainter = TextPainter(
text: TextSpan(
text: _formatPrice(close),
style: const TextStyle(color: Colors.white, fontSize: 9),
),
textDirection: ui.TextDirection.ltr,
);
textPainter.layout();
final labelRect = RRect.fromRectAndRadius(
Rect.fromLTWH(
size.width - 50,
y - textPainter.height / 2 - 2,
textPainter.width + 8,
textPainter.height + 4,
),
const Radius.circular(2),
);
canvas.drawRRect(labelRect, Paint()..color = _crossLineColor);
textPainter.paint(
canvas,
Offset(size.width - 50 + 4, y - textPainter.height / 2),
);
}
void _drawLegend(Canvas canvas, Size size, double leftPadding, double topPadding) {
final textPainter = TextPainter(textDirection: ui.TextDirection.ltr);
double x = leftPadding;
final y = 4.0;
if (maData != null) {
int colorIndex = 0;
for (final entry in maData!.entries) {
final lastValue = entry.value.lastWhere((v) => v != null, orElse: () => null);
if (lastValue != null) {
textPainter.text = TextSpan(
text: 'MA${entry.key}: ${_formatPrice(lastValue)}',
style: TextStyle(color: _maColors[colorIndex % _maColors.length], fontSize: 9),
);
textPainter.layout();
textPainter.paint(canvas, Offset(x, y));
x += textPainter.width + 8;
}
colorIndex++;
}
}
if (emaData != null) {
int colorIndex = 0;
for (final entry in emaData!.entries) {
final lastValue = entry.value.lastWhere((v) => v != null, orElse: () => null);
if (lastValue != null) {
textPainter.text = TextSpan(
text: 'EMA${entry.key}: ${_formatPrice(lastValue)}',
style: TextStyle(color: _maColors[colorIndex % _maColors.length], fontSize: 9),
);
textPainter.layout();
textPainter.paint(canvas, Offset(x, y));
x += textPainter.width + 8;
}
colorIndex++;
}
}
if (bollData != null) {
final middle = bollData!['middle']?.lastWhere((v) => v != null, orElse: () => null);
final upper = bollData!['upper']?.lastWhere((v) => v != null, orElse: () => null);
final lower = bollData!['lower']?.lastWhere((v) => v != null, orElse: () => null);
if (middle != null) {
textPainter.text = TextSpan(
text: 'BOLL: ${_formatPrice(middle)}',
style: const TextStyle(color: _bollMiddleColor, fontSize: 9),
);
textPainter.layout();
textPainter.paint(canvas, Offset(x, y));
x += textPainter.width + 8;
}
}
}
String _formatPrice(double price) {
if (price >= 1) return price.toStringAsFixed(4);
if (price >= 0.0001) return price.toStringAsFixed(6);
return price.toStringAsExponential(2);
}
@override
bool shouldRepaint(covariant KlinePainter oldDelegate) {
return oldDelegate.klines != klines ||
oldDelegate.maData != maData ||
oldDelegate.emaData != emaData ||
oldDelegate.bollData != bollData ||
oldDelegate.crossLineIndex != crossLineIndex;
}
}

View File

@ -0,0 +1,136 @@
import 'dart:math' as math;
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import '../../../domain/entities/kline.dart';
///
class KlineVolumePainter extends CustomPainter {
final List<Kline> klines;
final int crossLineIndex;
static const Color _green = Color(0xFF10B981);
static const Color _red = Color(0xFFEF4444);
static const Color _gridColor = Color(0xFFE5E7EB);
static const Color _textColor = Color(0xFF6B7280);
KlineVolumePainter({
required this.klines,
this.crossLineIndex = -1,
});
@override
void paint(Canvas canvas, Size size) {
if (klines.isEmpty) return;
const leftPadding = 8.0;
const rightPadding = 50.0;
const topPadding = 4.0;
const bottomPadding = 4.0;
final chartWidth = size.width - leftPadding - rightPadding;
final chartHeight = size.height - topPadding - bottomPadding;
//
double maxVolume = 0;
for (final kline in klines) {
final volume = double.tryParse(kline.volume) ?? 0;
if (volume > maxVolume) maxVolume = volume;
}
if (maxVolume == 0) maxVolume = 1; //
// 线
final gridPaint = Paint()
..color = _gridColor
..strokeWidth = 0.5;
canvas.drawLine(
Offset(leftPadding, 0),
Offset(size.width - rightPadding, 0),
gridPaint,
);
//
final textPainter = TextPainter(
text: TextSpan(
text: 'VOL',
style: const TextStyle(color: _textColor, fontSize: 9),
),
textDirection: ui.TextDirection.ltr,
);
textPainter.layout();
textPainter.paint(canvas, Offset(leftPadding, 2));
//
final maxVolText = TextPainter(
text: TextSpan(
text: _formatVolume(maxVolume),
style: const TextStyle(color: _textColor, fontSize: 8),
),
textDirection: ui.TextDirection.ltr,
);
maxVolText.layout();
maxVolText.paint(canvas, Offset(size.width - rightPadding + 4, topPadding));
//
final candleWidth = chartWidth / klines.length;
final barWidth = math.max(candleWidth * 0.7, 2.0);
for (int i = 0; i < klines.length; i++) {
final kline = klines[i];
final volume = double.tryParse(kline.volume) ?? 0;
final open = double.tryParse(kline.open) ?? 0;
final close = double.tryParse(kline.close) ?? 0;
final isUp = close >= open;
final color = isUp ? _green : _red;
final x = leftPadding + i * candleWidth + candleWidth / 2;
final barHeight = (volume / maxVolume) * chartHeight;
final y = size.height - bottomPadding - barHeight;
// 线
final alpha = (crossLineIndex >= 0 && crossLineIndex != i) ? 0.4 : 1.0;
canvas.drawRect(
Rect.fromLTRB(x - barWidth / 2, y, x + barWidth / 2, size.height - bottomPadding),
Paint()..color = color.withOpacity(alpha),
);
}
// 线
if (crossLineIndex >= 0 && crossLineIndex < klines.length) {
final x = leftPadding + crossLineIndex * candleWidth + candleWidth / 2;
canvas.drawLine(
Offset(x, 0),
Offset(x, size.height),
Paint()
..color = const Color(0xFF9CA3AF)
..strokeWidth = 0.5,
);
//
final volume = double.tryParse(klines[crossLineIndex].volume) ?? 0;
final volText = TextPainter(
text: TextSpan(
text: _formatVolume(volume),
style: const TextStyle(color: _textColor, fontSize: 9, fontWeight: FontWeight.bold),
),
textDirection: ui.TextDirection.ltr,
);
volText.layout();
volText.paint(canvas, Offset(leftPadding + 30, 2));
}
}
String _formatVolume(double volume) {
if (volume >= 1000000000) return '${(volume / 1000000000).toStringAsFixed(2)}B';
if (volume >= 1000000) return '${(volume / 1000000).toStringAsFixed(2)}M';
if (volume >= 1000) return '${(volume / 1000).toStringAsFixed(2)}K';
return volume.toStringAsFixed(0);
}
@override
bool shouldRepaint(covariant KlineVolumePainter oldDelegate) {
return oldDelegate.klines != klines || oldDelegate.crossLineIndex != crossLineIndex;
}
}

View File

@ -36,6 +36,7 @@ dependencies:
flutter_svg: ^2.0.7
cached_network_image: ^3.3.0
shimmer: ^3.0.0
qr_flutter: ^4.1.0
# 图表
fl_chart: ^0.64.0