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:
parent
3ce8bb0044
commit
20a90fce4c
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>()),
|
||||
);
|
||||
|
|
@ -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);
|
||||
},
|
||||
);
|
||||
|
|
@ -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});
|
||||
}
|
||||
|
|
@ -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(布林带)
|
||||
/// 默认周期20,标准差倍数2
|
||||
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]!);
|
||||
}
|
||||
}
|
||||
|
||||
// 计算DEA(DIF的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
|
||||
/// 周期9,K平滑3,D平滑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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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-100,但J可能超出
|
||||
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;
|
||||
}
|
||||
|
||||
// 绘制参考线(20、50、80)
|
||||
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;
|
||||
}
|
||||
|
||||
// 绘制参考线(30、50、70)
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue