diff --git a/frontend/mining-app/lib/core/network/api_endpoints.dart b/frontend/mining-app/lib/core/network/api_endpoints.dart index 689b1fe0..85ba9a43 100644 --- a/frontend/mining-app/lib/core/network/api_endpoints.dart +++ b/frontend/mining-app/lib/core/network/api_endpoints.dart @@ -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) => diff --git a/frontend/mining-app/lib/core/router/app_router.dart b/frontend/mining-app/lib/core/router/app_router.dart index e162bb82..b1b78438 100644 --- a/frontend/mining-app/lib/core/router/app_router.dart +++ b/frontend/mining-app/lib/core/router/app_router.dart @@ -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((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: [ diff --git a/frontend/mining-app/lib/core/router/routes.dart b/frontend/mining-app/lib/core/router/routes.dart index bbfa33f8..95977a5b 100644 --- a/frontend/mining-app/lib/core/router/routes.dart +++ b/frontend/mining-app/lib/core/router/routes.dart @@ -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'; } diff --git a/frontend/mining-app/lib/data/datasources/remote/trading_remote_datasource.dart b/frontend/mining-app/lib/data/datasources/remote/trading_remote_datasource.dart index b598e226..095a3d84 100644 --- a/frontend/mining-app/lib/data/datasources/remote/trading_remote_datasource.dart +++ b/frontend/mining-app/lib/data/datasources/remote/trading_remote_datasource.dart @@ -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> getKlines({String period = '1h', int limit = 100}); + + /// P2P转账 - 发送积分股给其他用户 + Future p2pTransfer({ + required String toPhone, + required String amount, + String? memo, + }); + + /// 查询账户信息(通过手机号) + Future lookupAccount(String phone); + + /// 获取P2P转账历史 + Future> getP2pTransferHistory(String accountSequence); + + // ============ C2C交易接口 ============ + + /// 获取C2C订单列表(市场广告) + Future getC2cOrders({ + String? type, // BUY/SELL + int page = 1, + int pageSize = 20, + }); + + /// 获取我的C2C订单 + Future getMyC2cOrders({ + String? status, + int page = 1, + int pageSize = 20, + }); + + /// 创建C2C订单(发布广告) + Future createC2cOrder({ + required String type, // BUY/SELL + required String price, + required String quantity, + String? minAmount, + String? maxAmount, + String? remark, + }); + + /// 获取C2C订单详情 + Future getC2cOrderDetail(String orderNo); + + /// 接单(吃单) + Future takeC2cOrder(String orderNo, {String? quantity}); + + /// 取消C2C订单 + Future cancelC2cOrder(String orderNo); + + /// 确认付款(买方操作) + Future confirmC2cPayment(String orderNo); + + /// 确认收款(卖方操作) + Future confirmC2cReceived(String orderNo); } class TradingRemoteDataSourceImpl implements TradingRemoteDataSource { @@ -220,4 +276,194 @@ class TradingRemoteDataSourceImpl implements TradingRemoteDataSource { throw ServerException(e.toString()); } } + + @override + Future p2pTransfer({ + required String toPhone, + required String amount, + String? memo, + }) async { + try { + final data = { + '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 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> getP2pTransferHistory(String accountSequence) async { + try { + final response = await client.get( + ApiEndpoints.p2pTransferHistory(accountSequence), + ); + final List data = response.data; + return data.map((json) => P2pTransferModel.fromJson(json)).toList(); + } catch (e) { + throw ServerException(e.toString()); + } + } + + // ============ C2C交易实现 ============ + + @override + Future getC2cOrders({ + String? type, + int page = 1, + int pageSize = 20, + }) async { + try { + final queryParams = { + '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 getMyC2cOrders({ + String? status, + int page = 1, + int pageSize = 20, + }) async { + try { + final queryParams = { + '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 createC2cOrder({ + required String type, + required String price, + required String quantity, + String? minAmount, + String? maxAmount, + String? remark, + }) async { + try { + final data = { + '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 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 takeC2cOrder(String orderNo, {String? quantity}) async { + try { + final data = {}; + 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 cancelC2cOrder(String orderNo) async { + try { + await client.post(ApiEndpoints.c2cCancelOrder(orderNo)); + } catch (e) { + throw ServerException(e.toString()); + } + } + + @override + Future 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 confirmC2cReceived(String orderNo) async { + try { + final response = await client.post( + ApiEndpoints.c2cConfirmReceived(orderNo), + ); + return C2cOrderModel.fromJson(response.data); + } catch (e) { + throw ServerException(e.toString()); + } + } } diff --git a/frontend/mining-app/lib/data/models/c2c_order_model.dart b/frontend/mining-app/lib/data/models/c2c_order_model.dart new file mode 100644 index 00000000..90029354 --- /dev/null +++ b/frontend/mining-app/lib/data/models/c2c_order_model.dart @@ -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 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 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 json) { + final List 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, + ); + } +} diff --git a/frontend/mining-app/lib/data/models/p2p_transfer_model.dart b/frontend/mining-app/lib/data/models/p2p_transfer_model.dart new file mode 100644 index 00000000..d3d933d9 --- /dev/null +++ b/frontend/mining-app/lib/data/models/p2p_transfer_model.dart @@ -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 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 json) { + return AccountLookupModel( + accountSequence: json['accountSequence'] ?? '', + phone: json['phone'] ?? '', + nickname: json['nickname'], + exists: json['exists'] ?? false, + ); + } +} diff --git a/frontend/mining-app/lib/presentation/pages/asset/asset_page.dart b/frontend/mining-app/lib/presentation/pages/asset/asset_page.dart index 2b2a56ba..4c0d31f3 100644 --- a/frontend/mining-app/lib/presentation/pages/asset/asset_page.dart +++ b/frontend/mining-app/lib/presentation/pages/asset/asset_page.dart @@ -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, + ), ), - ), - ], + ], + ), ); } diff --git a/frontend/mining-app/lib/presentation/pages/asset/receive_shares_page.dart b/frontend/mining-app/lib/presentation/pages/asset/receive_shares_page.dart new file mode 100644 index 00000000..52f970e0 --- /dev/null +++ b/frontend/mining-app/lib/presentation/pages/asset/receive_shares_page.dart @@ -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, + ), + ), + ], + ), + ); + } +} diff --git a/frontend/mining-app/lib/presentation/pages/asset/send_shares_page.dart b/frontend/mining-app/lib/presentation/pages/asset/send_shares_page.dart new file mode 100644 index 00000000..5c98493b --- /dev/null +++ b/frontend/mining-app/lib/presentation/pages/asset/send_shares_page.dart @@ -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 createState() => _SendSharesPageState(); +} + +class _SendSharesPageState extends ConsumerState { + 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 _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 _handleTransfer() async { + final confirmed = await showDialog( + 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, + ), + ); + } + } +} diff --git a/frontend/mining-app/lib/presentation/pages/c2c/c2c_market_page.dart b/frontend/mining-app/lib/presentation/pages/c2c/c2c_market_page.dart new file mode 100644 index 00000000..5b87b30f --- /dev/null +++ b/frontend/mining-app/lib/presentation/pages/c2c/c2c_market_page.dart @@ -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 createState() => _C2cMarketPageState(); +} + +class _C2cMarketPageState extends ConsumerState + 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 _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, + ), + ); + } + } +} diff --git a/frontend/mining-app/lib/presentation/pages/c2c/c2c_order_detail_page.dart b/frontend/mining-app/lib/presentation/pages/c2c/c2c_order_detail_page.dart new file mode 100644 index 00000000..0ccd22b0 --- /dev/null +++ b/frontend/mining-app/lib/presentation/pages/c2c/c2c_order_detail_page.dart @@ -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 createState() => _C2cOrderDetailPageState(); +} + +class _C2cOrderDetailPageState extends ConsumerState { + 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 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 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 _handleCancel(C2cOrderModel order) async { + final confirmed = await showDialog( + 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 _handleConfirmPayment(C2cOrderModel order) async { + final confirmed = await showDialog( + 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 _handleConfirmReceived(C2cOrderModel order) async { + final confirmed = await showDialog( + 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, + ), + ); + } + } +} diff --git a/frontend/mining-app/lib/presentation/pages/c2c/c2c_publish_page.dart b/frontend/mining-app/lib/presentation/pages/c2c/c2c_publish_page.dart new file mode 100644 index 00000000..40fb1f30 --- /dev/null +++ b/frontend/mining-app/lib/presentation/pages/c2c/c2c_publish_page.dart @@ -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 createState() => _C2cPublishPageState(); +} + +class _C2cPublishPageState extends ConsumerState { + 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 _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( + 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, + ), + ); + } + } +} diff --git a/frontend/mining-app/lib/presentation/pages/trading/trading_page.dart b/frontend/mining-app/lib/presentation/pages/trading/trading_page.dart index 4d4fe582..c6a6fa9f 100644 --- a/frontend/mining-app/lib/presentation/pages/trading/trading_page.dart +++ b/frontend/mining-app/lib/presentation/pages/trading/trading_page.dart @@ -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 { // 状态 int _selectedTab = 1; // 0: 买入, 1: 卖出 - int _selectedTimeRange = 1; // 时间周期选择 + int _selectedTimeRange = 4; // 时间周期选择,默认1时 final _quantityController = TextEditingController(); final _priceController = TextEditingController(); + bool _isFullScreen = false; // K线图全屏状态 final List _timeRanges = ['1分', '5分', '15分', '30分', '1时', '4时', '日']; @@ -52,9 +53,26 @@ class _TradingPageState extends ConsumerState { 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 { ref.invalidate(currentPriceProvider); ref.invalidate(marketOverviewProvider); ref.invalidate(ordersProvider); + ref.invalidate(klinesProvider); }, child: Column( children: [ @@ -73,7 +92,7 @@ class _TradingPageState extends ConsumerState { child: Column( children: [ _buildPriceCard(priceAsync), - _buildChartSection(priceAsync), + _buildChartSection(priceAsync, klinesAsync), _buildMarketDataCard(marketAsync), _buildTradingPanel(priceAsync), _buildMyOrdersCard(ordersAsync), @@ -181,98 +200,34 @@ class _TradingPageState extends ConsumerState { ); } - Widget _buildChartSection(AsyncValue priceAsync) { + Widget _buildChartSection(AsyncValue priceAsync, AsyncValue> 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 { 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 { ), ), 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 { ); } + 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 { } 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( + 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 { ); 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 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; - } -} diff --git a/frontend/mining-app/lib/presentation/providers/c2c_providers.dart b/frontend/mining-app/lib/presentation/providers/c2c_providers.dart new file mode 100644 index 00000000..68e20647 --- /dev/null +++ b/frontend/mining-app/lib/presentation/providers/c2c_providers.dart @@ -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( + (ref, type) async { + final dataSource = getIt(); + 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( + (ref) async { + final dataSource = getIt(); + 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( + (ref, orderNo) async { + final dataSource = getIt(); + 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 { + final TradingRemoteDataSource _dataSource; + + C2cTradingNotifier(this._dataSource) : super(C2cTradingState()); + + /// 创建C2C订单(发布广告) + Future 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 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 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 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 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( + (ref) => C2cTradingNotifier(getIt()), +); diff --git a/frontend/mining-app/lib/presentation/providers/transfer_providers.dart b/frontend/mining-app/lib/presentation/providers/transfer_providers.dart new file mode 100644 index 00000000..a25b3e14 --- /dev/null +++ b/frontend/mining-app/lib/presentation/providers/transfer_providers.dart @@ -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 { + final TradingRemoteDataSource _dataSource; + + TransferNotifier(this._dataSource) : super(TransferState()); + + /// 查询收款方账户 + Future 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 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( + (ref) => TransferNotifier(getIt()), +); + +/// P2P转账历史记录 +final p2pTransferHistoryProvider = + FutureProvider.family, String>( + (ref, accountSequence) async { + final dataSource = getIt(); + return dataSource.getP2pTransferHistory(accountSequence); + }, +); diff --git a/frontend/mining-app/lib/presentation/widgets/kline_chart/kline_chart_widget.dart b/frontend/mining-app/lib/presentation/widgets/kline_chart/kline_chart_widget.dart new file mode 100644 index 00000000..e10db74d --- /dev/null +++ b/frontend/mining-app/lib/presentation/widgets/kline_chart/kline_chart_widget.dart @@ -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 klines; + final String currentPrice; + final bool showVolume; + final bool isFullScreen; + final VoidCallback? onFullScreenToggle; + final List 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 createState() => _KlineChartWidgetState(); +} + +class _KlineChartWidgetState extends State { + // 颜色定义 + 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> _getVisibleMAData(Map> fullData, int startIndex) { + final result = >{}; + 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> _getVisibleBollData(Map> fullData, int startIndex) { + final result = >{}; + 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> _getVisibleMacdData(Map> fullData, int startIndex) { + final result = >{}; + 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> _getVisibleKdjData(Map> fullData, int startIndex) { + final result = >{}; + 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 _getVisibleRsiData(List 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 klines; + final int startIndex; + + _VisibleData({required this.klines, required this.startIndex}); +} diff --git a/frontend/mining-app/lib/presentation/widgets/kline_chart/kline_data_processor.dart b/frontend/mining-app/lib/presentation/widgets/kline_chart/kline_data_processor.dart new file mode 100644 index 00000000..c9287caf --- /dev/null +++ b/frontend/mining-app/lib/presentation/widgets/kline_chart/kline_data_processor.dart @@ -0,0 +1,299 @@ +import 'dart:math' as math; +import '../../../domain/entities/kline.dart'; + +/// K线数据处理器 - 计算各种技术指标 +class KlineDataProcessor { + final List klines; + + KlineDataProcessor(this.klines); + + /// 计算MA(简单移动平均线) + /// periods: [5, 10, 20, 60] 等周期 + Map> calculateMA(List periods) { + final result = >{}; + + for (final period in periods) { + final maList = []; + 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> calculateEMA(List periods) { + final result = >{}; + + for (final period in periods) { + final emaList = []; + 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> calculateBOLL({int period = 20, double stdDev = 2}) { + final middle = []; + final upper = []; + final lower = []; + + 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> calculateMACD({ + int fastPeriod = 12, + int slowPeriod = 26, + int signalPeriod = 9, + }) { + final dif = []; + final dea = []; + final macd = []; + + // 计算快线和慢线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 _calculateSingleEMA(int period) { + final emaList = []; + 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> calculateKDJ({ + int period = 9, + int kSmooth = 3, + int dSmooth = 3, + }) { + final k = []; + final d = []; + final j = []; + + 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 calculateRSI({int period = 14}) { + final rsiList = []; + 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; + } +} diff --git a/frontend/mining-app/lib/presentation/widgets/kline_chart/kline_indicator_painter.dart b/frontend/mining-app/lib/presentation/widgets/kline_chart/kline_indicator_painter.dart new file mode 100644 index 00000000..a74254b7 --- /dev/null +++ b/frontend/mining-app/lib/presentation/widgets/kline_chart/kline_indicator_painter.dart @@ -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>? macdData; + final Map>? kdjData; + final List? 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 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 dif, + List dea, + List 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 k, + List d, + List 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 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; + } +} diff --git a/frontend/mining-app/lib/presentation/widgets/kline_chart/kline_painter.dart b/frontend/mining-app/lib/presentation/widgets/kline_chart/kline_painter.dart new file mode 100644 index 00000000..ceaf1ce0 --- /dev/null +++ b/frontend/mining-app/lib/presentation/widgets/kline_chart/kline_painter.dart @@ -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 klines; + final Map>? maData; + final Map>? emaData; + final Map>? 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 _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 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; + } +} diff --git a/frontend/mining-app/lib/presentation/widgets/kline_chart/kline_volume_painter.dart b/frontend/mining-app/lib/presentation/widgets/kline_chart/kline_volume_painter.dart new file mode 100644 index 00000000..6dea2b7c --- /dev/null +++ b/frontend/mining-app/lib/presentation/widgets/kline_chart/kline_volume_painter.dart @@ -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 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; + } +} diff --git a/frontend/mining-app/pubspec.yaml b/frontend/mining-app/pubspec.yaml index 983476ba..685865ac 100644 --- a/frontend/mining-app/pubspec.yaml +++ b/frontend/mining-app/pubspec.yaml @@ -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