111
This commit is contained in:
@@ -41,6 +41,12 @@ class ApiEndpoints {
|
||||
/// 搜索幣種
|
||||
static const String coinSearch = '/api/market/search';
|
||||
|
||||
/// 訂單簿深度
|
||||
static const String marketDepth = '/api/market/depth';
|
||||
|
||||
/// 客服联系
|
||||
static const String customerService = '/api/market/customer-service';
|
||||
|
||||
// ==================== 交易模塊 ====================
|
||||
|
||||
/// 買入
|
||||
@@ -55,6 +61,9 @@ class ApiEndpoints {
|
||||
/// 獲取訂單詳情
|
||||
static const String tradeOrderDetail = '/api/trade/order/detail';
|
||||
|
||||
/// 撤销限价委托
|
||||
static const String tradeCancel = '/api/trade/cancel';
|
||||
|
||||
// ==================== 資產模塊 ====================
|
||||
|
||||
/// 獲取資產總覽
|
||||
|
||||
@@ -23,7 +23,6 @@ class DioClient {
|
||||
DioClient() {
|
||||
_dio = _createDio();
|
||||
_setupInterceptors();
|
||||
debugPrint('DioClient initialized with baseUrl: ${NetworkConfig.baseUrl}');
|
||||
}
|
||||
|
||||
Dio _createDio() {
|
||||
@@ -100,7 +99,6 @@ class DioClient {
|
||||
// 注意:不再自動清除用戶數據,避免誤判
|
||||
// 只有在 HTTP 401 時才清除用戶數據
|
||||
if (apiResponse.isUnauthorized) {
|
||||
debugPrint('業務層未授權響應: ${apiResponse.message}');
|
||||
// 不再自動調用 onUnauthorized,避免刷新時誤判
|
||||
// onUnauthorized?.call();
|
||||
}
|
||||
@@ -110,15 +108,6 @@ class DioClient {
|
||||
}
|
||||
|
||||
ApiResponse<T> _handleError<T>(DioException e) {
|
||||
// 詳細錯誤日誌
|
||||
debugPrint('=== Network Error ===');
|
||||
debugPrint('Type: ${e.type}');
|
||||
debugPrint('Message: ${e.message}');
|
||||
debugPrint('URL: ${e.requestOptions.uri}');
|
||||
debugPrint('StatusCode: ${e.response?.statusCode}');
|
||||
debugPrint('ResponseData: ${e.response?.data}');
|
||||
debugPrint('====================');
|
||||
|
||||
if (_isUnauthorized(e)) {
|
||||
_clearUserData();
|
||||
onUnauthorized?.call();
|
||||
@@ -165,32 +154,16 @@ class DioClient {
|
||||
class _LoggingInterceptor extends Interceptor {
|
||||
@override
|
||||
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
|
||||
debugPrint('┌──────────────────────────────────────────────────────────');
|
||||
debugPrint('│ REQUEST: ${options.method} ${options.uri}');
|
||||
debugPrint('│ Headers: ${options.headers}');
|
||||
if (options.data != null) {
|
||||
debugPrint('│ Data: ${options.data}');
|
||||
}
|
||||
debugPrint('└──────────────────────────────────────────────────────────');
|
||||
super.onRequest(options, handler);
|
||||
}
|
||||
|
||||
@override
|
||||
void onResponse(Response response, ResponseInterceptorHandler handler) {
|
||||
debugPrint('┌──────────────────────────────────────────────────────────');
|
||||
debugPrint('│ RESPONSE: ${response.statusCode} ${response.requestOptions.uri}');
|
||||
debugPrint('│ Data: ${response.data}');
|
||||
debugPrint('└──────────────────────────────────────────────────────────');
|
||||
super.onResponse(response, handler);
|
||||
}
|
||||
|
||||
@override
|
||||
void onError(DioException err, ErrorInterceptorHandler handler) {
|
||||
debugPrint('┌──────────────────────────────────────────────────────────');
|
||||
debugPrint('│ ERROR: ${err.type} ${err.requestOptions.uri}');
|
||||
debugPrint('│ Message: ${err.message}');
|
||||
debugPrint('│ StatusCode: ${err.response?.statusCode}');
|
||||
debugPrint('└──────────────────────────────────────────────────────────');
|
||||
super.onError(err, handler);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +36,6 @@ class DomainNavigator {
|
||||
static Future<String> init() async {
|
||||
if (kDebugMode) {
|
||||
_activeUrl = _debugUrl;
|
||||
debugPrint('[DomainNavigator] Debug mode, use: $_activeUrl');
|
||||
return _activeUrl;
|
||||
}
|
||||
|
||||
@@ -47,11 +46,9 @@ class DomainNavigator {
|
||||
final ok = await _quickCheck(cached);
|
||||
if (ok) {
|
||||
_activeUrl = cached;
|
||||
debugPrint('[DomainNavigator] Cache hit: $_activeUrl');
|
||||
return _activeUrl;
|
||||
}
|
||||
// 缓存验证失败但不删除,作为后续兜底
|
||||
debugPrint('[DomainNavigator] Cache check failed, keeping as fallback: $cached');
|
||||
}
|
||||
|
||||
// 竞速请求导航地址
|
||||
@@ -61,7 +58,6 @@ class DomainNavigator {
|
||||
// 缓存到本地
|
||||
await LocalStorage.setString(_cachedDomainKey, _activeUrl);
|
||||
|
||||
debugPrint('[DomainNavigator] Resolved: $_activeUrl');
|
||||
return _activeUrl;
|
||||
}
|
||||
|
||||
@@ -75,18 +71,15 @@ class DomainNavigator {
|
||||
|
||||
if (result.isNotEmpty) return result;
|
||||
} catch (_) {
|
||||
debugPrint('[DomainNavigator] Race resolve timeout or failed');
|
||||
}
|
||||
|
||||
// 所有导航失败,尝试使用上次缓存
|
||||
final cached = LocalStorage.getString(_cachedDomainKey);
|
||||
if (cached != null && cached.isNotEmpty) {
|
||||
debugPrint('[DomainNavigator] All nav failed, use cache: $cached');
|
||||
return cached;
|
||||
}
|
||||
|
||||
// 没有缓存,返回空字符串(应用会显示网络错误)
|
||||
debugPrint('[DomainNavigator] All nav failed, no cache available');
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -113,7 +106,7 @@ class DomainNavigator {
|
||||
if (url.startsWith('http')) return url;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
return 'https://admin.doskrleti.com';
|
||||
}
|
||||
|
||||
/// 快速检查域名是否可达(3s 超时)
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'app_color_scheme.dart';
|
||||
import 'app_spacing.dart';
|
||||
import 'app_theme_extension.dart';
|
||||
|
||||
const String _kFontFamily = 'Inter';
|
||||
|
||||
/// "The Kinetic Vault" & "The Ethereal Terminal" 主題配置
|
||||
class AppTheme {
|
||||
AppTheme._();
|
||||
@@ -19,6 +20,7 @@ class AppTheme {
|
||||
scaffoldBackgroundColor: AppColorScheme.darkBackground,
|
||||
primaryColor: AppColorScheme.darkPrimary,
|
||||
colorScheme: AppColorScheme.darkMaterial,
|
||||
fontFamily: _kFontFamily,
|
||||
extensions: <ThemeExtension<dynamic>>[
|
||||
AppThemeColors.dark(),
|
||||
],
|
||||
@@ -30,7 +32,7 @@ class AppTheme {
|
||||
elevation: 0,
|
||||
scrolledUnderElevation: 0,
|
||||
centerTitle: true,
|
||||
titleTextStyle: GoogleFonts.inter(
|
||||
titleTextStyle: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColorScheme.darkOnSurface,
|
||||
@@ -84,7 +86,7 @@ class AppTheme {
|
||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||
),
|
||||
elevation: 0,
|
||||
textStyle: GoogleFonts.inter(
|
||||
textStyle: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
@@ -125,6 +127,7 @@ class AppTheme {
|
||||
scaffoldBackgroundColor: AppColorScheme.lightBackground,
|
||||
primaryColor: AppColorScheme.lightPrimary,
|
||||
colorScheme: AppColorScheme.lightMaterial,
|
||||
fontFamily: _kFontFamily,
|
||||
extensions: <ThemeExtension<dynamic>>[
|
||||
AppThemeColors.light(),
|
||||
],
|
||||
@@ -136,7 +139,7 @@ class AppTheme {
|
||||
elevation: 0,
|
||||
scrolledUnderElevation: 0,
|
||||
centerTitle: true,
|
||||
titleTextStyle: GoogleFonts.inter(
|
||||
titleTextStyle: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColorScheme.lightOnSurface,
|
||||
@@ -190,7 +193,7 @@ class AppTheme {
|
||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||
),
|
||||
elevation: 0,
|
||||
textStyle: GoogleFonts.inter(
|
||||
textStyle: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
@@ -234,7 +237,8 @@ class AppTextStyles {
|
||||
// ============================================
|
||||
|
||||
/// D1 - 總資產/餘額 (28px w700) — Pencil $hero-value
|
||||
static TextStyle displayLarge(BuildContext context) => GoogleFonts.inter(
|
||||
static TextStyle displayLarge(BuildContext context) => TextStyle(
|
||||
fontFamily: _kFontFamily,
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
@@ -243,7 +247,8 @@ class AppTextStyles {
|
||||
);
|
||||
|
||||
/// D2 - 精選價格 (24px w700) — Pencil 行情卡片價格
|
||||
static TextStyle displayMedium(BuildContext context) => GoogleFonts.inter(
|
||||
static TextStyle displayMedium(BuildContext context) => TextStyle(
|
||||
fontFamily: _kFontFamily,
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
@@ -251,7 +256,8 @@ class AppTextStyles {
|
||||
);
|
||||
|
||||
/// D3 - 頁面標題 (22px w700) — Pencil 頁面大標題
|
||||
static TextStyle displaySmall(BuildContext context) => GoogleFonts.inter(
|
||||
static TextStyle displaySmall(BuildContext context) => TextStyle(
|
||||
fontFamily: _kFontFamily,
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
@@ -263,7 +269,8 @@ class AppTextStyles {
|
||||
// ============================================
|
||||
|
||||
/// 區塊/導航標題 (16px w600) — Pencil $section-title
|
||||
static TextStyle headlineLarge(BuildContext context) => GoogleFonts.inter(
|
||||
static TextStyle headlineLarge(BuildContext context) => TextStyle(
|
||||
fontFamily: _kFontFamily,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
@@ -271,7 +278,8 @@ class AppTextStyles {
|
||||
);
|
||||
|
||||
/// 卡片標題/價格/標籤頁 (14px w600) — Pencil $card-title
|
||||
static TextStyle headlineMedium(BuildContext context) => GoogleFonts.inter(
|
||||
static TextStyle headlineMedium(BuildContext context) => TextStyle(
|
||||
fontFamily: _kFontFamily,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
@@ -279,7 +287,8 @@ class AppTextStyles {
|
||||
);
|
||||
|
||||
/// 副標題/持倉價值 (13px w500)
|
||||
static TextStyle headlineSmall(BuildContext context) => GoogleFonts.inter(
|
||||
static TextStyle headlineSmall(BuildContext context) => TextStyle(
|
||||
fontFamily: _kFontFamily,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
@@ -291,7 +300,8 @@ class AppTextStyles {
|
||||
// ============================================
|
||||
|
||||
/// 主要正文 (13px w400)
|
||||
static TextStyle bodyLarge(BuildContext context) => GoogleFonts.inter(
|
||||
static TextStyle bodyLarge(BuildContext context) => TextStyle(
|
||||
fontFamily: _kFontFamily,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
@@ -299,7 +309,8 @@ class AppTextStyles {
|
||||
);
|
||||
|
||||
/// 次要正文/副標題 (12px w400) — Pencil $subtitle
|
||||
static TextStyle bodyMedium(BuildContext context) => GoogleFonts.inter(
|
||||
static TextStyle bodyMedium(BuildContext context) => TextStyle(
|
||||
fontFamily: _kFontFamily,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
@@ -307,7 +318,8 @@ class AppTextStyles {
|
||||
);
|
||||
|
||||
/// 輔助文字/幣種全名 (11px w400) — Pencil $small-text
|
||||
static TextStyle bodySmall(BuildContext context) => GoogleFonts.inter(
|
||||
static TextStyle bodySmall(BuildContext context) => TextStyle(
|
||||
fontFamily: _kFontFamily,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
@@ -319,7 +331,8 @@ class AppTextStyles {
|
||||
// ============================================
|
||||
|
||||
/// 按鈕/標籤頁標籤 (12px w500) — Pencil $tab-label
|
||||
static TextStyle labelLarge(BuildContext context) => GoogleFonts.inter(
|
||||
static TextStyle labelLarge(BuildContext context) => TextStyle(
|
||||
fontFamily: _kFontFamily,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
@@ -327,7 +340,8 @@ class AppTextStyles {
|
||||
);
|
||||
|
||||
/// 漲跌幅標籤 (11px w500) — Pencil $change-badge
|
||||
static TextStyle labelMedium(BuildContext context) => GoogleFonts.inter(
|
||||
static TextStyle labelMedium(BuildContext context) => TextStyle(
|
||||
fontFamily: _kFontFamily,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
@@ -335,7 +349,8 @@ class AppTextStyles {
|
||||
);
|
||||
|
||||
/// 漲跌幅標籤-粗 (11px w600) — Pencil $change-badge-bold
|
||||
static TextStyle labelSmall(BuildContext context) => GoogleFonts.inter(
|
||||
static TextStyle labelSmall(BuildContext context) => TextStyle(
|
||||
fontFamily: _kFontFamily,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
@@ -347,7 +362,8 @@ class AppTextStyles {
|
||||
// ============================================
|
||||
|
||||
/// 大號數字 (22px w700) - 總資產、餘額
|
||||
static TextStyle numberLarge(BuildContext context) => GoogleFonts.inter(
|
||||
static TextStyle numberLarge(BuildContext context) => TextStyle(
|
||||
fontFamily: _kFontFamily,
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
@@ -357,7 +373,8 @@ class AppTextStyles {
|
||||
);
|
||||
|
||||
/// 中號數字 (14px w600) - 價格、金額
|
||||
static TextStyle numberMedium(BuildContext context) => GoogleFonts.inter(
|
||||
static TextStyle numberMedium(BuildContext context) => TextStyle(
|
||||
fontFamily: _kFontFamily,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
@@ -366,7 +383,8 @@ class AppTextStyles {
|
||||
);
|
||||
|
||||
/// 小號數字 (12px w500) - 漲跌幅、數量
|
||||
static TextStyle numberSmall(BuildContext context) => GoogleFonts.inter(
|
||||
static TextStyle numberSmall(BuildContext context) => TextStyle(
|
||||
fontFamily: _kFontFamily,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
|
||||
@@ -14,6 +14,12 @@ class Coin {
|
||||
final double? volume24h;
|
||||
final int status;
|
||||
final int sort;
|
||||
final int isPlatform;
|
||||
final double? todayProfitRate; // 今日盈利比率(平台代币)
|
||||
final String? tradeStartAm;
|
||||
final String? tradeEndAm;
|
||||
final String? tradeStartPm;
|
||||
final String? tradeEndPm;
|
||||
|
||||
Coin({
|
||||
required this.id,
|
||||
@@ -30,6 +36,12 @@ class Coin {
|
||||
this.volume24h,
|
||||
required this.status,
|
||||
this.sort = 0,
|
||||
this.isPlatform = 0,
|
||||
this.todayProfitRate,
|
||||
this.tradeStartAm,
|
||||
this.tradeEndAm,
|
||||
this.tradeStartPm,
|
||||
this.tradeEndPm,
|
||||
});
|
||||
|
||||
factory Coin.fromJson(Map<String, dynamic> json) {
|
||||
@@ -48,6 +60,12 @@ class Coin {
|
||||
volume24h: (json['volume24h'] as num?)?.toDouble(),
|
||||
status: json['status'] as int? ?? 1,
|
||||
sort: json['sort'] as int? ?? 0,
|
||||
isPlatform: json['isPlatform'] as int? ?? 0,
|
||||
todayProfitRate: (json['todayProfitRate'] as num?)?.toDouble(),
|
||||
tradeStartAm: json['tradeStartAm'] as String?,
|
||||
tradeEndAm: json['tradeEndAm'] as String?,
|
||||
tradeStartPm: json['tradeStartPm'] as String?,
|
||||
tradeEndPm: json['tradeEndPm'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -67,6 +85,7 @@ class Coin {
|
||||
'volume24h': volume24h,
|
||||
'status': status,
|
||||
'sort': sort,
|
||||
'isPlatform': isPlatform,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,8 @@ class OrderTrade {
|
||||
final String price;
|
||||
final String quantity;
|
||||
final String amount;
|
||||
final int status; // 1=待處理, 2=已完成, 3=已取消
|
||||
final int status; // 0=委托中, 1=已成交, 2=失败, 3=已取消
|
||||
final int? orderType; // 1=市价单, 2=限价单
|
||||
final DateTime? createTime;
|
||||
final DateTime? updateTime;
|
||||
|
||||
@@ -22,6 +23,7 @@ class OrderTrade {
|
||||
required this.quantity,
|
||||
required this.amount,
|
||||
required this.status,
|
||||
this.orderType,
|
||||
this.createTime,
|
||||
this.updateTime,
|
||||
});
|
||||
@@ -37,6 +39,7 @@ class OrderTrade {
|
||||
quantity: json['quantity']?.toString() ?? '0',
|
||||
amount: json['amount']?.toString() ?? '0.00',
|
||||
status: json['status'] as int? ?? 1,
|
||||
orderType: json['orderType'] as int?,
|
||||
createTime: json['createTime'] != null
|
||||
? DateTime.tryParse(json['createTime'])
|
||||
: null,
|
||||
@@ -52,10 +55,12 @@ class OrderTrade {
|
||||
/// 狀態文字
|
||||
String get statusText {
|
||||
switch (status) {
|
||||
case 0:
|
||||
return '委托中';
|
||||
case 1:
|
||||
return '待處理';
|
||||
return '已成交';
|
||||
case 2:
|
||||
return '已完成';
|
||||
return '失败';
|
||||
case 3:
|
||||
return '已取消';
|
||||
default:
|
||||
@@ -63,8 +68,14 @@ class OrderTrade {
|
||||
}
|
||||
}
|
||||
|
||||
/// 是否为委托中
|
||||
bool get isPending => status == 0;
|
||||
|
||||
/// 是否為買入
|
||||
bool get isBuy => direction == 1;
|
||||
|
||||
/// 订单类型文字
|
||||
String get orderTypeText => orderType == 2 ? '限价' : '市价';
|
||||
}
|
||||
|
||||
/// 充提訂單模型
|
||||
|
||||
@@ -53,4 +53,17 @@ class MarketService {
|
||||
}
|
||||
return ApiResponse.fail(response.message ?? '搜索失敗');
|
||||
}
|
||||
|
||||
/// 獲取訂單簿深度
|
||||
Future<ApiResponse<Map<String, dynamic>>> getDepth(String code) async {
|
||||
final response = await _client.get<Map<String, dynamic>>(
|
||||
ApiEndpoints.marketDepth,
|
||||
queryParameters: {'code': code},
|
||||
);
|
||||
|
||||
if (response.success && response.data != null) {
|
||||
return ApiResponse.success(response.data!, response.message);
|
||||
}
|
||||
return ApiResponse.fail(response.message ?? '獲取深度失敗');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ class TradeService {
|
||||
required String coinCode,
|
||||
required String price,
|
||||
required String quantity,
|
||||
int orderType = 1, // 1=市价单, 2=限价单
|
||||
}) async {
|
||||
return _client.post<Map<String, dynamic>>(
|
||||
ApiEndpoints.buy,
|
||||
@@ -21,6 +22,7 @@ class TradeService {
|
||||
'coinCode': coinCode,
|
||||
'price': price,
|
||||
'quantity': quantity,
|
||||
'orderType': orderType,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -30,6 +32,7 @@ class TradeService {
|
||||
required String coinCode,
|
||||
required String price,
|
||||
required String quantity,
|
||||
int orderType = 1, // 1=市价单, 2=限价单
|
||||
}) async {
|
||||
return _client.post<Map<String, dynamic>>(
|
||||
ApiEndpoints.sell,
|
||||
@@ -37,6 +40,7 @@ class TradeService {
|
||||
'coinCode': coinCode,
|
||||
'price': price,
|
||||
'quantity': quantity,
|
||||
'orderType': orderType,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -76,4 +80,12 @@ class TradeService {
|
||||
}
|
||||
return ApiResponse.fail(response.message ?? '獲取訂單詳情失敗');
|
||||
}
|
||||
|
||||
/// 撤销限价委托
|
||||
Future<ApiResponse<Map<String, dynamic>>> cancelOrder(String orderNo) async {
|
||||
return _client.post<Map<String, dynamic>>(
|
||||
ApiEndpoints.tradeCancel,
|
||||
data: {'orderNo': orderNo},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,29 +18,34 @@ import 'data/services/trade_service.dart';
|
||||
import 'data/services/asset_service.dart';
|
||||
import 'data/services/fund_service.dart';
|
||||
import 'data/services/bonus_service.dart';
|
||||
import 'data/services/config_service.dart';
|
||||
import 'providers/auth_provider.dart';
|
||||
import 'providers/market_provider.dart';
|
||||
import 'providers/asset_provider.dart';
|
||||
import 'providers/trade_provider.dart';
|
||||
import 'providers/theme_provider.dart';
|
||||
import 'ui/pages/auth/login_page.dart';
|
||||
import 'ui/pages/main/main_page.dart';
|
||||
import 'ui/pages/onboarding/onboarding_page.dart';
|
||||
|
||||
/// 全局導航 Key,用於從任意位置跳轉到登錄頁
|
||||
final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
|
||||
|
||||
void main() async {
|
||||
// 確保 Flutter 綁定初始化
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
// 防截图:Android 端通过 FLAG_SECURE(在 MainActivity 中已设置)
|
||||
// Web 端通过 index.html 中的 JS/CSS 防护
|
||||
// iOS 端暂无原生防截图能力(Web 模式下同样由 JS 保护)
|
||||
|
||||
// 全局錯誤處理 - Flutter 框架錯誤
|
||||
FlutterError.onError = (FlutterErrorDetails details) {
|
||||
FlutterError.presentError(details);
|
||||
debugPrint('Flutter Error: ${details.exception}');
|
||||
debugPrint('Stack trace: ${details.stack}');
|
||||
};
|
||||
|
||||
// 全局錯誤處理 - 異步未捕獲錯誤
|
||||
PlatformDispatcher.instance.onError = (error, stack) {
|
||||
debugPrint('Uncaught error: $error');
|
||||
debugPrint('Stack: $stack');
|
||||
return true;
|
||||
};
|
||||
|
||||
@@ -54,12 +59,9 @@ void main() async {
|
||||
final resolvedUrl = await DomainNavigator.init();
|
||||
ApiEndpoints.init(resolvedUrl);
|
||||
|
||||
debugPrint('App initialized with domain: $resolvedUrl');
|
||||
} catch (e, stack) {
|
||||
// 域名解析失敗時使用兜底地址
|
||||
ApiEndpoints.init(DomainNavigator.activeUrl);
|
||||
debugPrint('Initialization error: $e');
|
||||
debugPrint('Stack: $stack');
|
||||
}
|
||||
|
||||
runApp(const MyApp());
|
||||
@@ -97,6 +99,7 @@ class MyApp extends StatelessWidget {
|
||||
Provider<AssetService>(create: (_) => AssetService(dioClient)),
|
||||
Provider<FundService>(create: (_) => FundService(dioClient)),
|
||||
Provider<BonusService>(create: (_) => BonusService(dioClient)),
|
||||
Provider<ConfigService>(create: (_) => ConfigService(dioClient)),
|
||||
// State Management
|
||||
ChangeNotifierProvider<AuthProvider>(
|
||||
create: (ctx) {
|
||||
@@ -113,14 +116,22 @@ class MyApp extends StatelessWidget {
|
||||
create: (ctx) => AssetProvider(
|
||||
ctx.read<AssetService>(),
|
||||
ctx.read<FundService>(),
|
||||
ctx.read<TradeService>(),
|
||||
ctx.read<AppEventBus>(),
|
||||
),
|
||||
),
|
||||
ChangeNotifierProvider<TradeProvider>(
|
||||
create: (ctx) => TradeProvider(
|
||||
ctx.read<MarketService>(),
|
||||
ctx.read<TradeService>(),
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
Widget _buildMaterialApp(BuildContext context, ThemeMode themeMode) {
|
||||
return MaterialApp(
|
||||
navigatorKey: navigatorKey,
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: AppTheme.lightTheme,
|
||||
darkTheme: AppTheme.darkTheme,
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import '../core/network/api_response.dart';
|
||||
import '../core/network/dio_client.dart';
|
||||
import '../core/event/app_event_bus.dart';
|
||||
import '../data/models/account_models.dart';
|
||||
import '../data/models/order_models.dart';
|
||||
import '../data/services/asset_service.dart';
|
||||
import '../data/services/fund_service.dart';
|
||||
import '../data/services/trade_service.dart';
|
||||
|
||||
/// 資產狀態管理
|
||||
class AssetProvider extends ChangeNotifier {
|
||||
final AssetService _assetService;
|
||||
final FundService _fundService;
|
||||
final TradeService _tradeService;
|
||||
final AppEventBus _eventBus;
|
||||
|
||||
AssetOverview? _overview;
|
||||
@@ -18,6 +20,7 @@ class AssetProvider extends ChangeNotifier {
|
||||
List<AccountTrade> _tradeAccounts = [];
|
||||
List<AccountFlow> _flows = [];
|
||||
List<OrderFund> _fundOrders = [];
|
||||
List<OrderTrade> _tradeOrders = [];
|
||||
bool _isLoading = false;
|
||||
bool _isLoadingFlows = false;
|
||||
bool _isLoadingOrders = false;
|
||||
@@ -28,7 +31,10 @@ class AssetProvider extends ChangeNotifier {
|
||||
bool _fundAccountLoaded = false;
|
||||
bool _tradeAccountLoaded = false;
|
||||
|
||||
AssetProvider(this._assetService, this._fundService, this._eventBus);
|
||||
// 去重:防止并发调用同一 API
|
||||
Completer<void>? _refreshAllCompleter;
|
||||
|
||||
AssetProvider(this._assetService, this._fundService, this._tradeService, this._eventBus);
|
||||
|
||||
// Getters
|
||||
AssetOverview? get overview => _overview;
|
||||
@@ -37,6 +43,7 @@ class AssetProvider extends ChangeNotifier {
|
||||
List<AccountTrade> get holdings => _tradeAccounts;
|
||||
List<AccountFlow> get flows => _flows;
|
||||
List<OrderFund> get fundOrders => _fundOrders;
|
||||
List<OrderTrade> get tradeOrders => _tradeOrders;
|
||||
bool get isLoading => _isLoading;
|
||||
bool get isLoadingFlows => _isLoadingFlows;
|
||||
bool get isLoadingOrders => _isLoadingOrders;
|
||||
@@ -232,6 +239,22 @@ class AssetProvider extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// 加載交易記錄
|
||||
Future<void> loadTradeOrders({int? direction, int pageNum = 1, int pageSize = 20}) async {
|
||||
try {
|
||||
final response = await _tradeService.getOrders(
|
||||
direction: direction,
|
||||
pageNum: pageNum,
|
||||
pageSize: pageSize,
|
||||
);
|
||||
if (response.success && response.data != null) {
|
||||
final list = response.data!['list'] as List?;
|
||||
_tradeOrders = list?.map((e) => OrderTrade.fromJson(e as Map<String, dynamic>)).toList() ?? [];
|
||||
notifyListeners();
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
/// 取消訂單
|
||||
Future<ApiResponse<void>> cancelOrder(String orderNo) async {
|
||||
try {
|
||||
@@ -261,13 +284,24 @@ class AssetProvider extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
/// 刷新所有資產數據
|
||||
/// 刷新所有資產數據(带去重)
|
||||
Future<void> refreshAll({bool force = false}) async {
|
||||
await Future.wait([
|
||||
loadOverview(force: force),
|
||||
loadFundAccount(force: force),
|
||||
loadTradeAccount(force: force),
|
||||
]);
|
||||
if (_refreshAllCompleter != null && !_refreshAllCompleter!.isCompleted) {
|
||||
return _refreshAllCompleter!.future;
|
||||
}
|
||||
_refreshAllCompleter = Completer<void>();
|
||||
try {
|
||||
await Future.wait([
|
||||
loadOverview(force: force),
|
||||
loadFundAccount(force: force),
|
||||
loadTradeAccount(force: force),
|
||||
]);
|
||||
_refreshAllCompleter!.complete();
|
||||
} catch (e) {
|
||||
if (!_refreshAllCompleter!.isCompleted) _refreshAllCompleter!.completeError(e);
|
||||
} finally {
|
||||
_refreshAllCompleter = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 重置加載狀態(用於退出登錄時)
|
||||
@@ -280,6 +314,7 @@ class AssetProvider extends ChangeNotifier {
|
||||
_tradeAccounts = [];
|
||||
_flows = [];
|
||||
_fundOrders = [];
|
||||
_tradeOrders = [];
|
||||
_error = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import '../core/network/dio_client.dart';
|
||||
import '../core/storage/local_storage.dart';
|
||||
import '../data/models/user.dart';
|
||||
import '../data/services/user_service.dart';
|
||||
import '../main.dart';
|
||||
import '../ui/pages/auth/login_page.dart';
|
||||
|
||||
/// 認證狀態管理
|
||||
class AuthProvider extends ChangeNotifier {
|
||||
@@ -112,16 +114,13 @@ class AuthProvider extends ChangeNotifier {
|
||||
|
||||
/// 退出登錄
|
||||
Future<void> logout() async {
|
||||
_setLoading(true);
|
||||
|
||||
try {
|
||||
await _userService.logout();
|
||||
} catch (_) {
|
||||
// 忽略退出登錄的接口錯誤
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
_clearAuthState();
|
||||
_setLoading(false);
|
||||
notifyListeners();
|
||||
_navigateToLogin();
|
||||
}
|
||||
|
||||
void _clearAuthState() {
|
||||
@@ -135,6 +134,18 @@ class AuthProvider extends ChangeNotifier {
|
||||
void forceLogout() {
|
||||
_clearAuthState();
|
||||
notifyListeners();
|
||||
_navigateToLogin();
|
||||
}
|
||||
|
||||
/// 使用全局導航 Key 跳轉到登錄頁並清空路由棧
|
||||
void _navigateToLogin() {
|
||||
final nav = navigatorKey.currentState;
|
||||
if (nav != null) {
|
||||
nav.pushAndRemoveUntil(
|
||||
MaterialPageRoute(builder: (_) => LoginPage()),
|
||||
(route) => false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 刷新用戶信息
|
||||
|
||||
@@ -7,6 +7,8 @@ class MarketProvider extends ChangeNotifier {
|
||||
final MarketService _marketService;
|
||||
|
||||
List<Coin> _allCoins = [];
|
||||
List<Coin> _platformCoins = [];
|
||||
List<Coin> _nonPlatformCoins = [];
|
||||
bool _isLoading = false;
|
||||
String? _error;
|
||||
bool _coinsLoaded = false;
|
||||
@@ -18,14 +20,8 @@ class MarketProvider extends ChangeNotifier {
|
||||
bool get isLoading => _isLoading;
|
||||
String? get error => _error;
|
||||
|
||||
/// BTC 和 ETH(上半區展示)
|
||||
List<Coin> get featuredCoins =>
|
||||
_allCoins.where((c) => c.code == 'BTC' || c.code == 'ETH').toList();
|
||||
|
||||
/// 排除 BTC、ETH、USDT 的代幣列表(下半區展示)
|
||||
List<Coin> get otherCoins => _allCoins
|
||||
.where((c) => !{'BTC', 'ETH', 'USDT'}.contains(c.code))
|
||||
.toList();
|
||||
List<Coin> get platformCoins => _platformCoins;
|
||||
List<Coin> get nonPlatformCoins => _nonPlatformCoins;
|
||||
|
||||
/// 加載幣種列表
|
||||
Future<void> loadCoins({bool force = false}) async {
|
||||
@@ -33,21 +29,37 @@ class MarketProvider extends ChangeNotifier {
|
||||
return;
|
||||
}
|
||||
|
||||
_isLoading = true;
|
||||
_error = null;
|
||||
notifyListeners();
|
||||
final isFirstLoad = !_coinsLoaded || _allCoins.isEmpty;
|
||||
if (isFirstLoad) {
|
||||
_isLoading = true;
|
||||
_error = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
try {
|
||||
final response = await _marketService.getCoinList();
|
||||
|
||||
if (response.success) {
|
||||
_allCoins = response.data ?? [];
|
||||
_platformCoins = _allCoins.where((c) => c.isPlatform == 1 && c.status == 1).toList();
|
||||
_nonPlatformCoins = _allCoins.where((c) => c.isPlatform != 1 && c.code != 'USDT').toList();
|
||||
_coinsLoaded = true;
|
||||
_error = null;
|
||||
} else {
|
||||
_error = response.message;
|
||||
// 非首次加载时保留旧数据,仅记录错误
|
||||
if (!isFirstLoad && _allCoins.isNotEmpty) {
|
||||
_error = null;
|
||||
} else {
|
||||
_error = response.message;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
_error = '加載失敗: $e';
|
||||
// 非首次加载且有缓存数据时,不覆盖为错误状态
|
||||
if (!isFirstLoad && _allCoins.isNotEmpty) {
|
||||
_error = null;
|
||||
} else {
|
||||
_error = '加載失敗: $e';
|
||||
}
|
||||
}
|
||||
|
||||
_isLoading = false;
|
||||
|
||||
@@ -5,6 +5,8 @@ import '../../../core/theme/app_spacing.dart';
|
||||
import '../../../core/theme/app_theme.dart';
|
||||
import '../../../core/event/app_event_bus.dart';
|
||||
import '../../../providers/asset_provider.dart';
|
||||
import '../../../providers/market_provider.dart';
|
||||
import '../main/main_page.dart';
|
||||
import 'components/action_buttons_row.dart';
|
||||
import 'components/balance_card.dart';
|
||||
import 'components/holdings_section.dart';
|
||||
@@ -24,6 +26,7 @@ class AssetPage extends StatefulWidget {
|
||||
|
||||
class _AssetPageState extends State<AssetPage> with AutomaticKeepAliveClientMixin {
|
||||
StreamSubscription<AppEvent>? _eventSub;
|
||||
Timer? _refreshTimer;
|
||||
|
||||
@override
|
||||
bool get wantKeepAlive => true;
|
||||
@@ -34,15 +37,27 @@ class _AssetPageState extends State<AssetPage> with AutomaticKeepAliveClientMixi
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_loadData();
|
||||
_listenEvents();
|
||||
_startAutoRefresh();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_eventSub?.cancel();
|
||||
_refreshTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _startAutoRefresh() {
|
||||
_refreshTimer?.cancel();
|
||||
_refreshTimer = Timer.periodic(const Duration(seconds: 15), (_) {
|
||||
if (!mounted) return;
|
||||
final mainState = context.findAncestorStateOfType<MainPageState>();
|
||||
if (mainState?.isPageVisible(3) != true) return;
|
||||
context.read<AssetProvider>().refreshAll(force: true);
|
||||
});
|
||||
}
|
||||
|
||||
void _listenEvents() {
|
||||
final eventBus = context.read<AppEventBus>();
|
||||
_eventSub = eventBus.on(AppEventType.assetChanged, (_) {
|
||||
@@ -65,25 +80,34 @@ class _AssetPageState extends State<AssetPage> with AutomaticKeepAliveClientMixi
|
||||
backgroundColor: colorScheme.background,
|
||||
body: Consumer<AssetProvider>(
|
||||
builder: (context, provider, _) {
|
||||
return RefreshIndicator(
|
||||
onRefresh: () => provider.refreshAll(force: true),
|
||||
color: colorScheme.primary,
|
||||
backgroundColor: colorScheme.surfaceContainerHighest,
|
||||
child: SingleChildScrollView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.fromLTRB(AppSpacing.md, AppSpacing.md + 8, AppSpacing.md, 32),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Page title
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 16, bottom: 8),
|
||||
child: Text(
|
||||
'資產',
|
||||
style: AppTextStyles.displaySmall(context),
|
||||
),
|
||||
return Column(
|
||||
children: [
|
||||
Container(
|
||||
height: 48,
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
'資產',
|
||||
style: AppTextStyles.headlineLarge(context).copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: RefreshIndicator(
|
||||
onRefresh: () => provider.refreshAll(force: true),
|
||||
color: colorScheme.primary,
|
||||
backgroundColor: colorScheme.surfaceContainerHighest,
|
||||
child: SingleChildScrollView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.only(
|
||||
top: AppSpacing.sm,
|
||||
left: AppSpacing.md,
|
||||
right: AppSpacing.md,
|
||||
bottom: AppSpacing.xl,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 資金賬戶 + 交易賬戶 左右並排
|
||||
Row(
|
||||
children: [
|
||||
@@ -119,10 +143,23 @@ class _AssetPageState extends State<AssetPage> with AutomaticKeepAliveClientMixi
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
// Holdings section
|
||||
HoldingsSection(holdings: provider.holdings),
|
||||
Builder(builder: (context) {
|
||||
final platformCodes = context.watch<MarketProvider>().platformCoins.map((c) => c.code).toSet();
|
||||
return HoldingsSection(
|
||||
holdings: provider.holdings,
|
||||
platformCoinCodes: platformCodes,
|
||||
onTapTrade: (code) {
|
||||
final mainState = context.findAncestorStateOfType<MainPageState>();
|
||||
mainState?.switchToTrade(code);
|
||||
},
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
@@ -5,15 +5,14 @@ import '../../../../core/theme/app_spacing.dart';
|
||||
import '../../../../data/models/account_models.dart';
|
||||
import '../../../components/glass_panel.dart';
|
||||
import '../../../components/coin_icon.dart';
|
||||
import '../../chart/chart_page.dart';
|
||||
|
||||
/// 持倉區域
|
||||
/// Header: "我的資產" + "查看全部 >"
|
||||
/// Holdings Card: cornerRadius lg, fill $surface-card, stroke $border-default 1px
|
||||
class HoldingsSection extends StatelessWidget {
|
||||
final List holdings;
|
||||
final Set<String> platformCoinCodes;
|
||||
final void Function(String coinCode)? onTapTrade;
|
||||
|
||||
const HoldingsSection({super.key, required this.holdings});
|
||||
const HoldingsSection({super.key, required this.holdings, this.platformCoinCodes = const {}, this.onTapTrade});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -60,6 +59,7 @@ class HoldingsSection extends StatelessWidget {
|
||||
children: List.generate(holdings.length, (index) {
|
||||
final h = holdings[index] as AccountTrade;
|
||||
final isProfit = h.profitRate >= 0;
|
||||
final isPlatform = platformCoinCodes.contains(h.coinCode);
|
||||
return Column(
|
||||
children: [
|
||||
HoldingRow(
|
||||
@@ -68,6 +68,8 @@ class HoldingsSection extends StatelessWidget {
|
||||
value: '${double.tryParse(h.currentValue)?.toStringAsFixed(2) ?? h.currentValue} USDT',
|
||||
profitRate: '${isProfit ? '+' : ''}${h.profitRate.toStringAsFixed(2)}%',
|
||||
isProfit: isProfit,
|
||||
isTradable: isPlatform,
|
||||
onTap: isPlatform ? () => onTapTrade?.call(h.coinCode) : null,
|
||||
),
|
||||
if (index < holdings.length - 1) const HoldingDivider(),
|
||||
],
|
||||
@@ -106,6 +108,8 @@ class HoldingRow extends StatelessWidget {
|
||||
final String value;
|
||||
final String profitRate;
|
||||
final bool isProfit;
|
||||
final bool isTradable;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const HoldingRow({
|
||||
super.key,
|
||||
@@ -114,6 +118,8 @@ class HoldingRow extends StatelessWidget {
|
||||
required this.value,
|
||||
required this.profitRate,
|
||||
required this.isProfit,
|
||||
this.isTradable = false,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -123,12 +129,7 @@ class HoldingRow extends StatelessWidget {
|
||||
final profitColor = isProfit ? context.appColors.up : context.appColors.down;
|
||||
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => ChartPage(symbol: coinCode)),
|
||||
);
|
||||
},
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md, vertical: 14),
|
||||
child: Row(
|
||||
|
||||
@@ -56,6 +56,10 @@ class _DepositPageState extends State<DepositPage> {
|
||||
ToastUtils.showError('單筆最低充值 1000 USDT');
|
||||
return;
|
||||
}
|
||||
if (n % 1000 != 0) {
|
||||
ToastUtils.showError('充值金額必須為1000的整數倍');
|
||||
return;
|
||||
}
|
||||
if (_isSubmitting) return;
|
||||
|
||||
setState(() => _isSubmitting = true);
|
||||
|
||||
@@ -47,17 +47,17 @@ class _TransferPageState extends State<TransferPage> {
|
||||
final provider = context.read<AssetProvider>();
|
||||
final balance = provider.fundAccount?.balance ??
|
||||
provider.overview?.fundBalance ??
|
||||
'0.00';
|
||||
return _formatBalance(balance);
|
||||
'0';
|
||||
return balance;
|
||||
} catch (e) {
|
||||
return '0.00';
|
||||
return '0';
|
||||
}
|
||||
}
|
||||
|
||||
String get _tradeUsdtBalance {
|
||||
try {
|
||||
final provider = context.read<AssetProvider>();
|
||||
if (provider.tradeAccounts.isEmpty) return '0.00';
|
||||
if (provider.tradeAccounts.isEmpty) return '0';
|
||||
final usdtHolding = provider.tradeAccounts.firstWhere(
|
||||
(t) => t.coinCode.toUpperCase() == 'USDT',
|
||||
orElse: () => AccountTrade(
|
||||
@@ -72,9 +72,9 @@ class _TransferPageState extends State<TransferPage> {
|
||||
profitRate: 0,
|
||||
),
|
||||
);
|
||||
return _formatBalance(usdtHolding.quantity);
|
||||
return usdtHolding.quantity;
|
||||
} catch (e) {
|
||||
return '0.00';
|
||||
return '0';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,12 +140,16 @@ class _TransferPageState extends State<TransferPage> {
|
||||
|
||||
void _setQuickAmount(double percent) {
|
||||
final available = double.tryParse(_availableBalance) ?? 0;
|
||||
final amount = available * percent;
|
||||
// 向下截斷到2位小數,避免四捨五入超出餘額
|
||||
_amountController.text =
|
||||
((amount * 100).truncateToDouble() / 100).toStringAsFixed(2);
|
||||
|
||||
// Trigger haptic feedback
|
||||
if (available <= 0) return;
|
||||
if (percent >= 1.0) {
|
||||
// 百分百直接用原始余额字符串,避免精度丢失
|
||||
_amountController.text = _availableBalance;
|
||||
} else {
|
||||
final amount = available * percent;
|
||||
// 向下截斷到2位小數,避免四捨五入超出餘額
|
||||
_amountController.text =
|
||||
((amount * 100).truncateToDouble() / 100).toStringAsFixed(2);
|
||||
}
|
||||
HapticFeedback.selectionClick();
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import '../../../core/theme/app_spacing.dart';
|
||||
import '../../../core/theme/app_theme.dart';
|
||||
import '../../../core/theme/app_theme_extension.dart';
|
||||
import '../../../providers/auth_provider.dart';
|
||||
import '../../../main.dart';
|
||||
import '../../components/material_input.dart';
|
||||
import '../main/main_page.dart';
|
||||
import 'register_page.dart';
|
||||
@@ -275,19 +276,15 @@ class _LoginPageState extends State<LoginPage> {
|
||||
if (!mounted) return;
|
||||
|
||||
if (response.success) {
|
||||
_navigateToMainPage();
|
||||
navigatorKey.currentState?.pushAndRemoveUntil(
|
||||
MaterialPageRoute(builder: (_) => const MainPage()),
|
||||
(route) => false,
|
||||
);
|
||||
} else {
|
||||
_showErrorDialog(response.message ?? '用戶名或密碼錯誤');
|
||||
}
|
||||
}
|
||||
|
||||
void _navigateToMainPage() {
|
||||
Navigator.of(context).pushAndRemoveUntil(
|
||||
MaterialPageRoute(builder: (context) => const MainPage()),
|
||||
(route) => false,
|
||||
);
|
||||
}
|
||||
|
||||
void _navigateToRegister() {
|
||||
Navigator.push(
|
||||
context,
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import '../../../core/theme/app_theme.dart';
|
||||
import '../../../core/theme/app_color_scheme.dart';
|
||||
import '../../../core/theme/app_spacing.dart';
|
||||
import '../../../providers/auth_provider.dart';
|
||||
import '../main/main_page.dart';
|
||||
|
||||
/// 首頁頂欄 - Logo + 搜索/通知/頭像
|
||||
/// 首頁頂欄 - Logo + 頭像
|
||||
class HeaderBar extends StatelessWidget {
|
||||
const HeaderBar({super.key});
|
||||
|
||||
@@ -30,37 +29,29 @@ class HeaderBar extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
// Search button
|
||||
_IconButton(
|
||||
icon: LucideIcons.search,
|
||||
colorScheme: colorScheme,
|
||||
onTap: () {},
|
||||
),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
// Bell button
|
||||
_IconButton(
|
||||
icon: LucideIcons.bell,
|
||||
colorScheme: colorScheme,
|
||||
onTap: () {},
|
||||
),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
// Avatar
|
||||
// Avatar — 点击跳转到"我的"页面
|
||||
Consumer<AuthProvider>(
|
||||
builder: (context, auth, _) {
|
||||
final username = auth.user?.username ?? '';
|
||||
final initial = username.isNotEmpty ? username[0].toUpperCase() : '?';
|
||||
return Container(
|
||||
width: 32,
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primary,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
initial,
|
||||
style: AppTextStyles.headlineMedium(context).copyWith(
|
||||
color: AppColorScheme.darkOnPrimary,
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
final mainState = context.findAncestorStateOfType<MainPageState>();
|
||||
mainState?.switchToTab(4);
|
||||
},
|
||||
child: Container(
|
||||
width: 32,
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primary,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
initial,
|
||||
style: AppTextStyles.headlineMedium(context).copyWith(
|
||||
color: colorScheme.onPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -71,36 +62,3 @@ class HeaderBar extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _IconButton extends StatelessWidget {
|
||||
const _IconButton({
|
||||
required this.icon,
|
||||
required this.colorScheme,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final ColorScheme colorScheme;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
width: 32,
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(AppRadius.xl),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Icon(
|
||||
icon,
|
||||
size: 16,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import '../../../core/theme/app_theme.dart';
|
||||
@@ -9,8 +10,11 @@ import '../../../core/event/app_event_bus.dart';
|
||||
import '../../../data/models/account_models.dart';
|
||||
import '../../../data/services/bonus_service.dart';
|
||||
import '../../../data/services/asset_service.dart';
|
||||
import '../../../data/services/config_service.dart';
|
||||
import '../../../providers/asset_provider.dart';
|
||||
import '../../../providers/market_provider.dart';
|
||||
import '../../components/glass_panel.dart';
|
||||
import '../main/main_page.dart';
|
||||
import '../mine/welfare_center_page.dart';
|
||||
import '../asset/transfer_page.dart';
|
||||
import '../asset/deposit_page.dart';
|
||||
@@ -33,7 +37,9 @@ class HomePage extends StatefulWidget {
|
||||
class _HomePageState extends State<HomePage>
|
||||
with AutomaticKeepAliveClientMixin {
|
||||
int _totalClaimable = 0;
|
||||
String _customerServiceContact = '';
|
||||
StreamSubscription<AppEvent>? _eventSub;
|
||||
Timer? _refreshTimer;
|
||||
|
||||
@override
|
||||
bool get wantKeepAlive => true;
|
||||
@@ -44,15 +50,28 @@ class _HomePageState extends State<HomePage>
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_loadData();
|
||||
_listenEvents();
|
||||
_startAutoRefresh();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_eventSub?.cancel();
|
||||
_refreshTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _startAutoRefresh() {
|
||||
_refreshTimer?.cancel();
|
||||
_refreshTimer = Timer.periodic(const Duration(seconds: 15), (_) {
|
||||
if (!mounted) return;
|
||||
final mainState = context.findAncestorStateOfType<MainPageState>();
|
||||
if (mainState?.isPageVisible(0) != true) return;
|
||||
context.read<AssetProvider>().refreshAll(force: true);
|
||||
context.read<MarketProvider>().loadCoins();
|
||||
});
|
||||
}
|
||||
|
||||
void _listenEvents() {
|
||||
final eventBus = context.read<AppEventBus>();
|
||||
_eventSub = eventBus.on(AppEventType.assetChanged, (_) {
|
||||
@@ -67,7 +86,9 @@ class _HomePageState extends State<HomePage>
|
||||
final provider = context.read<AssetProvider>();
|
||||
provider.loadOverview();
|
||||
provider.loadTradeAccount();
|
||||
context.read<MarketProvider>().loadCoins();
|
||||
_checkBonusStatus();
|
||||
_loadCustomerService();
|
||||
}
|
||||
|
||||
Future<void> _checkBonusStatus() async {
|
||||
@@ -82,6 +103,18 @@ class _HomePageState extends State<HomePage>
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<void> _loadCustomerService() async {
|
||||
try {
|
||||
final configService = context.read<ConfigService>();
|
||||
final response = await configService.getCustomerServiceContact();
|
||||
if (response.success && response.data != null && response.data!.isNotEmpty) {
|
||||
setState(() {
|
||||
_customerServiceContact = response.data!;
|
||||
});
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
super.build(context);
|
||||
@@ -130,6 +163,10 @@ class _HomePageState extends State<HomePage>
|
||||
MaterialPageRoute(builder: (_) => const WelfareCenterPage()),
|
||||
),
|
||||
),
|
||||
if (_customerServiceContact.isNotEmpty) ...[
|
||||
SizedBox(height: AppSpacing.md),
|
||||
_CustomerServiceCard(contact: _customerServiceContact),
|
||||
],
|
||||
SizedBox(height: AppSpacing.lg),
|
||||
// 熱門幣種
|
||||
HotCoinsSection(),
|
||||
@@ -684,3 +721,75 @@ class _ProfitStatCard extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 客服联系卡片
|
||||
class _CustomerServiceCard extends StatelessWidget {
|
||||
final String contact;
|
||||
const _CustomerServiceCard({required this.contact});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return GestureDetector(
|
||||
onLongPress: () {
|
||||
Clipboard.setData(ClipboardData(text: contact));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('已复制客服账号'), duration: Duration(seconds: 2)),
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: EdgeInsets.all(AppSpacing.lg),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(AppRadius.xl),
|
||||
border: Border.all(color: colorScheme.outlineVariant.withValues(alpha: 0.2)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primary.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||
),
|
||||
child: Icon(
|
||||
LucideIcons.headset,
|
||||
color: colorScheme.primary,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
SizedBox(width: AppSpacing.md),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'联系客服',
|
||||
style: AppTextStyles.headlineLarge(context).copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
SizedBox(height: AppSpacing.xs),
|
||||
Text(
|
||||
contact,
|
||||
style: AppTextStyles.bodyMedium(context).copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
LucideIcons.copy,
|
||||
size: 16,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../../core/theme/app_theme.dart';
|
||||
import '../../../core/theme/app_spacing.dart';
|
||||
import '../../../core/theme/app_theme_extension.dart';
|
||||
import '../../../data/models/coin.dart';
|
||||
import '../../../providers/market_provider.dart';
|
||||
import '../../components/coin_icon.dart';
|
||||
import '../main/main_page.dart';
|
||||
|
||||
/// 首頁熱門幣種區塊
|
||||
class HotCoinsSection extends StatelessWidget {
|
||||
@@ -10,153 +14,137 @@ class HotCoinsSection extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
// Title row
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'熱門幣種',
|
||||
style: AppTextStyles.headlineLarge(context),
|
||||
return Consumer<MarketProvider>(
|
||||
builder: (context, market, _) {
|
||||
// 所有币种,平台代币排首
|
||||
final platformCoins = market.platformCoins;
|
||||
final otherCoins = market.nonPlatformCoins;
|
||||
final coins = [...platformCoins, ...otherCoins];
|
||||
if (coins.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
// Title row
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'熱門幣種',
|
||||
style: AppTextStyles.headlineLarge(context),
|
||||
),
|
||||
Text(
|
||||
'更多',
|
||||
style: AppTextStyles.bodyMedium(context).copyWith(
|
||||
color: context.appColors.onSurfaceMuted,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Text(
|
||||
'更多',
|
||||
style: AppTextStyles.bodyMedium(context).copyWith(
|
||||
color: context.appColors.onSurfaceMuted,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.sm + AppSpacing.xs),
|
||||
// Card
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainer,
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.outlineVariant,
|
||||
width: 1,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(AppRadius.xl),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
_CoinRow(
|
||||
symbol: 'BTC',
|
||||
pair: 'BTC/USDT',
|
||||
fullName: 'Bitcoin',
|
||||
price: '68,432.50',
|
||||
change: '+2.35%',
|
||||
isUp: true,
|
||||
const SizedBox(height: AppSpacing.sm + AppSpacing.xs),
|
||||
// Card
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainer,
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.outlineVariant,
|
||||
width: 1,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(AppRadius.xl),
|
||||
),
|
||||
Divider(
|
||||
height: 1,
|
||||
thickness: 1,
|
||||
color: context.appColors.ghostBorder,
|
||||
child: Column(
|
||||
children: List.generate(coins.length, (index) {
|
||||
return Column(
|
||||
children: [
|
||||
_CoinRow(coin: coins[index]),
|
||||
if (index < coins.length - 1)
|
||||
Divider(
|
||||
height: 1,
|
||||
thickness: 1,
|
||||
color: context.appColors.ghostBorder,
|
||||
),
|
||||
],
|
||||
);
|
||||
}),
|
||||
),
|
||||
_CoinRow(
|
||||
symbol: 'ETH',
|
||||
pair: 'ETH/USDT',
|
||||
fullName: 'Ethereum',
|
||||
price: '3,856.20',
|
||||
change: '+1.82%',
|
||||
isUp: true,
|
||||
),
|
||||
Divider(
|
||||
height: 1,
|
||||
thickness: 1,
|
||||
color: context.appColors.ghostBorder,
|
||||
),
|
||||
_CoinRow(
|
||||
symbol: 'SOL',
|
||||
pair: 'SOL/USDT',
|
||||
fullName: 'Solana',
|
||||
price: '178.65',
|
||||
change: '-0.94%',
|
||||
isUp: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CoinRow extends StatelessWidget {
|
||||
const _CoinRow({
|
||||
required this.symbol,
|
||||
required this.pair,
|
||||
required this.fullName,
|
||||
required this.price,
|
||||
required this.change,
|
||||
required this.isUp,
|
||||
});
|
||||
const _CoinRow({required this.coin});
|
||||
|
||||
final String symbol;
|
||||
final String pair;
|
||||
final String fullName;
|
||||
final String price;
|
||||
final String change;
|
||||
final bool isUp;
|
||||
final Coin coin;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final changeColor = isUp
|
||||
final changeColor = coin.isUp
|
||||
? context.appColors.up
|
||||
: context.appColors.down;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: AppSpacing.md),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
// Left: avatar + name
|
||||
Row(
|
||||
children: [
|
||||
CoinIcon(
|
||||
symbol: symbol,
|
||||
size: 36,
|
||||
isCircle: false,
|
||||
),
|
||||
const SizedBox(width: AppSpacing.sm + AppSpacing.xs),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
pair,
|
||||
style: AppTextStyles.headlineMedium(context).copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
fullName,
|
||||
style: AppTextStyles.bodySmall(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
// Right: price + change
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
price,
|
||||
style: AppTextStyles.numberMedium(context),
|
||||
),
|
||||
Text(
|
||||
change,
|
||||
style: AppTextStyles.labelMedium(context).copyWith(
|
||||
color: changeColor,
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
if (coin.isPlatform == 1) {
|
||||
final mainState = context.findAncestorStateOfType<MainPageState>();
|
||||
mainState?.switchToTrade(coin.code);
|
||||
}
|
||||
},
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: AppSpacing.md),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
// Left: avatar + name
|
||||
Row(
|
||||
children: [
|
||||
CoinIcon(
|
||||
symbol: coin.code,
|
||||
size: 36,
|
||||
isCircle: false,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
const SizedBox(width: AppSpacing.sm + AppSpacing.xs),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'${coin.code}/USDT',
|
||||
style: AppTextStyles.headlineMedium(context).copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
coin.name,
|
||||
style: AppTextStyles.bodySmall(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
// Right: price + change
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
'\$${coin.formattedPrice}',
|
||||
style: AppTextStyles.numberMedium(context),
|
||||
),
|
||||
Text(
|
||||
coin.formattedChange,
|
||||
style: AppTextStyles.labelMedium(context).copyWith(
|
||||
color: changeColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,18 +27,23 @@ class MainPage extends StatefulWidget {
|
||||
State<MainPage> createState() => MainPageState();
|
||||
}
|
||||
|
||||
class MainPageState extends State<MainPage> {
|
||||
class MainPageState extends State<MainPage> with WidgetsBindingObserver {
|
||||
int _currentIndex = 0;
|
||||
final Set<int> _loadedPages = {0};
|
||||
String? _tradeCoinCode; // 交易頁面選中的幣種代碼
|
||||
late final List<Widget> _pages;
|
||||
bool _isAppVisible = true;
|
||||
|
||||
// 防抖:記錄上次刷新時間,同一 Tab 500ms 內不重複刷新
|
||||
final Map<int, DateTime> _lastRefreshTime = {};
|
||||
|
||||
/// 当前页面是否可见(供子页面判断是否需要轮询)
|
||||
bool isPageVisible(int pageIndex) => _currentIndex == pageIndex && _isAppVisible;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
_pages = [
|
||||
const HomePage(),
|
||||
const MarketPage(),
|
||||
@@ -48,6 +53,17 @@ class MainPageState extends State<MainPage> {
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
_isAppVisible = state == AppLifecycleState.resumed;
|
||||
}
|
||||
|
||||
void _onTabChanged(int index) {
|
||||
final wasLoaded = _loadedPages.contains(index);
|
||||
setState(() {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
@@ -18,6 +19,8 @@ class MarketPage extends StatefulWidget {
|
||||
|
||||
class _MarketPageState extends State<MarketPage>
|
||||
with AutomaticKeepAliveClientMixin {
|
||||
Timer? _refreshTimer;
|
||||
|
||||
@override
|
||||
bool get wantKeepAlive => true;
|
||||
|
||||
@@ -26,6 +29,23 @@ class _MarketPageState extends State<MarketPage>
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
context.read<MarketProvider>().loadCoins();
|
||||
_startAutoRefresh();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_refreshTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _startAutoRefresh() {
|
||||
_refreshTimer?.cancel();
|
||||
_refreshTimer = Timer.periodic(const Duration(seconds: 10), (_) {
|
||||
if (!mounted) return;
|
||||
final mainState = context.findAncestorStateOfType<MainPageState>();
|
||||
if (mainState?.isPageVisible(1) != true) return;
|
||||
context.read<MarketProvider>().loadCoins(force: true);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -46,109 +66,67 @@ class _MarketPageState extends State<MarketPage>
|
||||
return _buildErrorState(provider);
|
||||
}
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: () => provider.refresh(),
|
||||
color: colorScheme.primary,
|
||||
backgroundColor: colorScheme.surfaceContainerHighest,
|
||||
child: SingleChildScrollView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.only(
|
||||
top: AppSpacing.md,
|
||||
left: AppSpacing.md,
|
||||
right: AppSpacing.md,
|
||||
bottom: AppSpacing.xl,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'行情',
|
||||
style: AppTextStyles.displaySmall(context).copyWith(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
return Column(
|
||||
children: [
|
||||
Container(
|
||||
height: 48,
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
'行情',
|
||||
style: AppTextStyles.headlineLarge(context).copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: RefreshIndicator(
|
||||
onRefresh: () => provider.refresh(),
|
||||
color: colorScheme.primary,
|
||||
backgroundColor: colorScheme.surfaceContainerHighest,
|
||||
child: SingleChildScrollView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.only(
|
||||
top: AppSpacing.sm,
|
||||
left: AppSpacing.md,
|
||||
right: AppSpacing.md,
|
||||
bottom: AppSpacing.xl,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 平台代币区域 — 使用 Selector 精确监听
|
||||
Selector<MarketProvider, List<Coin>>(
|
||||
selector: (_, p) => p.platformCoins,
|
||||
builder: (_, platformCoins, __) {
|
||||
if (platformCoins.isEmpty) return const SizedBox.shrink();
|
||||
return Column(
|
||||
children: platformCoins.map((coin) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: _PlatformTokenCard(coin: coin),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
},
|
||||
),
|
||||
Selector<MarketProvider, List<Coin>>(
|
||||
selector: (_, p) => p.nonPlatformCoins,
|
||||
builder: (_, coins, __) => _buildCoinList(coins, provider),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
_buildSearchBar(colorScheme),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
_buildFeaturedSection(provider),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
_buildCoinList(provider),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 搜索框
|
||||
// ============================================
|
||||
|
||||
Widget _buildSearchBar(ColorScheme colorScheme) {
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
// TODO: 彈出搜索界面
|
||||
},
|
||||
child: Container(
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(LucideIcons.search,
|
||||
size: 16, color: colorScheme.onSurfaceVariant),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
Text(
|
||||
'搜索幣種名稱或代碼',
|
||||
style: AppTextStyles.bodyMedium(context).copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 精選區域
|
||||
// ============================================
|
||||
|
||||
Widget _buildFeaturedSection(MarketProvider provider) {
|
||||
final featured = provider.featuredCoins;
|
||||
if (featured.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
final btc = featured.where((c) => c.code == 'BTC').firstOrNull;
|
||||
final eth = featured.where((c) => c.code == 'ETH').firstOrNull;
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
if (btc != null)
|
||||
Expanded(child: _FeaturedCard(coin: btc))
|
||||
else
|
||||
const Expanded(child: SizedBox.shrink()),
|
||||
const SizedBox(width: 10),
|
||||
if (eth != null)
|
||||
Expanded(child: _FeaturedCard(coin: eth))
|
||||
else
|
||||
const Expanded(child: SizedBox.shrink()),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 幣種列表
|
||||
// ============================================
|
||||
|
||||
Widget _buildCoinList(MarketProvider provider) {
|
||||
Widget _buildCoinList(List<Coin> coins, MarketProvider provider) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final coins = provider.otherCoins;
|
||||
|
||||
if (coins.isEmpty) {
|
||||
return _EmptyState(
|
||||
@@ -192,79 +170,34 @@ class _MarketPageState extends State<MarketPage>
|
||||
Widget _buildListHeader(ColorScheme colorScheme) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
AppSpacing.md,
|
||||
AppSpacing.sm + AppSpacing.xs,
|
||||
AppSpacing.md,
|
||||
AppSpacing.sm,
|
||||
AppSpacing.md, AppSpacing.sm + AppSpacing.xs, AppSpacing.md, AppSpacing.sm,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'幣種',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 90,
|
||||
child: Text(
|
||||
'最新價',
|
||||
textAlign: TextAlign.right,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
child: Text('幣種', style: TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: colorScheme.onSurfaceVariant)),
|
||||
),
|
||||
SizedBox(width: 90, child: Text('最新價', textAlign: TextAlign.right, style: TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: colorScheme.onSurfaceVariant))),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
SizedBox(
|
||||
width: 72,
|
||||
child: Text(
|
||||
'漲跌幅',
|
||||
textAlign: TextAlign.right,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(width: 72, child: Text('漲跌幅', textAlign: TextAlign.right, style: TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: colorScheme.onSurfaceVariant))),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 錯誤狀態
|
||||
// ============================================
|
||||
|
||||
Widget _buildErrorState(MarketProvider provider) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: AppSpacing.pagePadding,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(LucideIcons.circleAlert,
|
||||
size: 48, color: colorScheme.error),
|
||||
Icon(LucideIcons.circleAlert, size: 48, color: colorScheme.error),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
Text(
|
||||
provider.error ?? '加載失敗',
|
||||
style: TextStyle(color: colorScheme.error),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
Text(provider.error ?? '加載失敗', style: TextStyle(color: colorScheme.error), textAlign: TextAlign.center),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
ElevatedButton(
|
||||
onPressed: () => provider.refresh(),
|
||||
child: const Text('重試'),
|
||||
),
|
||||
ElevatedButton(onPressed: () => provider.refresh(), child: const Text('重試')),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -273,94 +206,130 @@ class _MarketPageState extends State<MarketPage>
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 精選卡片 — 簡約風格,用貨幣圖標代替柱狀圖
|
||||
// 平台代币卡片 — 跟随主题色,简洁专业
|
||||
// ============================================
|
||||
|
||||
class _FeaturedCard extends StatelessWidget {
|
||||
class _PlatformTokenCard extends StatelessWidget {
|
||||
final Coin coin;
|
||||
|
||||
const _FeaturedCard({required this.coin});
|
||||
const _PlatformTokenCard({required this.coin});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final isUp = coin.isUp;
|
||||
final changeColor = isUp ? colorScheme.tertiary : colorScheme.error;
|
||||
|
||||
final changeColor =
|
||||
isUp ? colorScheme.tertiary : colorScheme.error;
|
||||
final changeBgColor =
|
||||
changeColor.withValues(alpha: 0.12);
|
||||
|
||||
return Container(
|
||||
height: 120,
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||
border: Border.all(
|
||||
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// 左側:圖標 + 交易對
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
CoinIcon(symbol: coin.code, size: 32),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'${coin.code}/U',
|
||||
style: AppTextStyles.bodyLarge(context).copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
final mainState = context.findAncestorStateOfType<MainPageState>();
|
||||
mainState?.switchToTrade(coin.code);
|
||||
},
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
|
||||
),
|
||||
const Spacer(),
|
||||
// 右側:價格 + 漲跌
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
_formatFeaturedPrice(coin),
|
||||
style: AppTextStyles.numberLarge(context).copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.xs + 2,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: changeBgColor,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
coin.formattedChange,
|
||||
style: TextStyle(
|
||||
color: changeColor,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 第一行:图标 + 币种名/交易对 + 涨跌幅
|
||||
Row(
|
||||
children: [
|
||||
CoinIcon(symbol: coin.code, size: 36),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
coin.code,
|
||||
style: TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'/USDT',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Text(
|
||||
coin.name,
|
||||
style: TextStyle(fontSize: 12, color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
_formatPrice(coin),
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
coin.formattedChange,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: changeColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
// 第二行:左侧统计 + 右侧交易入口
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'24h量 ${_fmtVol(coin.volume24h)}',
|
||||
style: TextStyle(fontSize: 11, color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
'交易 →',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: colorScheme.primary,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatFeaturedPrice(Coin coin) {
|
||||
if (coin.price >= 1000) {
|
||||
return _addCommas(coin.price.toStringAsFixed(2));
|
||||
}
|
||||
String _fmtVol(double? v) {
|
||||
if (v == null) return '--';
|
||||
if (v >= 1000000) return '${(v / 1000000).toStringAsFixed(2)}M';
|
||||
if (v >= 1000) return '${(v / 1000).toStringAsFixed(2)}K';
|
||||
return v.toStringAsFixed(2);
|
||||
}
|
||||
|
||||
String _formatPrice(Coin coin) {
|
||||
if (coin.price >= 1000) return _addCommas(coin.price.toStringAsFixed(2));
|
||||
return coin.price.toStringAsFixed(2);
|
||||
}
|
||||
|
||||
@@ -371,9 +340,7 @@ class _FeaturedCard extends StatelessWidget {
|
||||
final buffer = StringBuffer();
|
||||
int count = 0;
|
||||
for (int i = intPart.length - 1; i >= 0; i--) {
|
||||
if (count > 0 && count % 3 == 0) {
|
||||
buffer.write(',');
|
||||
}
|
||||
if (count > 0 && count % 3 == 0) buffer.write(',');
|
||||
buffer.write(intPart[i]);
|
||||
count++;
|
||||
}
|
||||
@@ -387,7 +354,6 @@ class _FeaturedCard extends StatelessWidget {
|
||||
|
||||
class _CoinRow extends StatelessWidget {
|
||||
final Coin coin;
|
||||
|
||||
const _CoinRow({required this.coin});
|
||||
|
||||
@override
|
||||
@@ -395,82 +361,39 @@ class _CoinRow extends StatelessWidget {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final isUp = coin.isUp;
|
||||
final changeColor = isUp ? colorScheme.tertiary : colorScheme.error;
|
||||
final changeBgColor = changeColor.withValues(alpha: 0.12);
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () => _navigateToTrade(context),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.md,
|
||||
vertical: 14,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
CoinIcon(
|
||||
symbol: coin.code,
|
||||
size: 36,
|
||||
isCircle: true,
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md, vertical: 14),
|
||||
child: Row(
|
||||
children: [
|
||||
CoinIcon(symbol: coin.code, size: 36, isCircle: false),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('${coin.code}/USDT', style: AppTextStyles.headlineMedium(context).copyWith(fontWeight: FontWeight.bold)),
|
||||
Text(coin.name, style: AppTextStyles.bodySmall(context)),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: AppSpacing.sm + AppSpacing.xs),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'${coin.code}/U',
|
||||
style: AppTextStyles.numberMedium(context).copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
coin.name,
|
||||
style: AppTextStyles.bodySmall(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
alignment: Alignment.centerRight,
|
||||
child: Text(coin.formattedPrice, style: AppTextStyles.numberMedium(context)),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 90,
|
||||
child: Text(
|
||||
coin.formattedPrice,
|
||||
textAlign: TextAlign.right,
|
||||
style: AppTextStyles.numberMedium(context),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
SizedBox(
|
||||
width: 72,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.xs + 2,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: changeBgColor,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
coin.formattedChange,
|
||||
textAlign: TextAlign.center,
|
||||
style: AppTextStyles.labelSmall(context).copyWith(
|
||||
color: changeColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Text(coin.formattedChange, style: AppTextStyles.labelMedium(context).copyWith(color: changeColor)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _navigateToTrade(BuildContext context) {
|
||||
final mainState = context.findAncestorStateOfType<MainPageState>();
|
||||
mainState?.switchToTrade(coin.code);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
@@ -482,16 +405,11 @@ class _EmptyState extends StatelessWidget {
|
||||
final String message;
|
||||
final VoidCallback? onRetry;
|
||||
|
||||
const _EmptyState({
|
||||
required this.icon,
|
||||
required this.message,
|
||||
this.onRetry,
|
||||
});
|
||||
const _EmptyState({required this.icon, required this.message, this.onRetry});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(AppSpacing.xl),
|
||||
@@ -499,16 +417,10 @@ class _EmptyState extends StatelessWidget {
|
||||
children: [
|
||||
Icon(icon, size: 48, color: colorScheme.onSurfaceVariant),
|
||||
const SizedBox(height: AppSpacing.sm + AppSpacing.xs),
|
||||
Text(
|
||||
message,
|
||||
style: TextStyle(color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
Text(message, style: TextStyle(color: colorScheme.onSurfaceVariant)),
|
||||
if (onRetry != null) ...[
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
ElevatedButton(
|
||||
onPressed: onRetry,
|
||||
child: const Text('重試'),
|
||||
),
|
||||
ElevatedButton(onPressed: onRetry, child: const Text('重試')),
|
||||
],
|
||||
],
|
||||
),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import '../change_password_page.dart';
|
||||
import '../kyc_page.dart';
|
||||
import '../welfare_center_page.dart';
|
||||
import 'menu_group_container.dart';
|
||||
@@ -56,8 +57,13 @@ class MenuGroup1 extends StatelessWidget {
|
||||
MenuRow(
|
||||
icon: LucideIcons.lock,
|
||||
iconColor: colorScheme.onSurfaceVariant,
|
||||
title: '安全設置',
|
||||
onTap: () => onShowComingSoon('安全設置'),
|
||||
title: '修改密碼',
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const ChangePasswordPage()),
|
||||
);
|
||||
},
|
||||
),
|
||||
const Divider(height: 1),
|
||||
MenuRow(
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import '../change_password_page.dart';
|
||||
import 'menu_group_container.dart';
|
||||
import 'menu_row.dart';
|
||||
import 'menu_trailing_widgets.dart';
|
||||
|
||||
/// 菜單分組2 - 深色模式 / 系統設置 / 關於我們
|
||||
/// 菜單分組2 - 深色模式 / 修改密碼
|
||||
class MenuGroup2 extends StatelessWidget {
|
||||
final VoidCallback onShowAbout;
|
||||
|
||||
const MenuGroup2({super.key, required this.onShowAbout});
|
||||
const MenuGroup2({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -17,26 +16,19 @@ class MenuGroup2 extends StatelessWidget {
|
||||
return MenuGroupContainer(
|
||||
child: Column(
|
||||
children: [
|
||||
// 深色模式
|
||||
const DarkModeRow(),
|
||||
const Divider(height: 1),
|
||||
// 系統設置
|
||||
MenuRow(
|
||||
icon: LucideIcons.settings,
|
||||
icon: LucideIcons.lock,
|
||||
iconColor: colorScheme.onSurfaceVariant,
|
||||
title: '系統設置',
|
||||
title: '修改密碼',
|
||||
onTap: () {
|
||||
// TODO: 系統設置
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const ChangePasswordPage()),
|
||||
);
|
||||
},
|
||||
),
|
||||
const Divider(height: 1),
|
||||
// 關於我們
|
||||
MenuRow(
|
||||
icon: LucideIcons.info,
|
||||
iconColor: colorScheme.onSurfaceVariant,
|
||||
title: '關於我們',
|
||||
onTap: onShowAbout,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import '../../../../core/theme/app_theme.dart';
|
||||
import 'avatar_circle.dart';
|
||||
@@ -41,10 +42,32 @@ class ProfileCard extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'普通用戶',
|
||||
style: AppTextStyles.bodySmall(context).copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
Clipboard.setData(ClipboardData(text: '${user?.id ?? ''}'));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('ID已复制'),
|
||||
duration: Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'ID: ${user?.id ?? ''}',
|
||||
style: AppTextStyles.bodySmall(context).copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Icon(
|
||||
LucideIcons.copy,
|
||||
size: 12,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -3,8 +3,6 @@ import 'package:provider/provider.dart';
|
||||
import '../../../core/theme/app_theme.dart';
|
||||
import '../../../core/theme/app_spacing.dart';
|
||||
import '../../../providers/auth_provider.dart';
|
||||
import '../auth/login_page.dart';
|
||||
import 'components/avatar_circle.dart';
|
||||
import 'components/logout_button.dart';
|
||||
import 'components/menu_group1.dart';
|
||||
import 'components/menu_group2.dart';
|
||||
@@ -48,7 +46,7 @@ class _MinePageState extends State<MinePage>
|
||||
onShowComingSoon: _showComingSoon,
|
||||
),
|
||||
SizedBox(height: AppSpacing.sm),
|
||||
MenuGroup2(onShowAbout: _showAboutDialog),
|
||||
const MenuGroup2(),
|
||||
SizedBox(height: AppSpacing.lg),
|
||||
LogoutButton(onLogout: () => _handleLogout(auth)),
|
||||
SizedBox(height: AppSpacing.md),
|
||||
@@ -76,36 +74,6 @@ class _MinePageState extends State<MinePage>
|
||||
);
|
||||
}
|
||||
|
||||
void _showAboutDialog() {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Row(
|
||||
children: [
|
||||
AvatarCircle(radius: 16, fontSize: 12),
|
||||
const SizedBox(width: 8),
|
||||
const Text('模擬所'),
|
||||
],
|
||||
),
|
||||
content: const Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('虛擬貨幣模擬交易平臺'),
|
||||
SizedBox(height: 8),
|
||||
Text('版本: 1.0.0'),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(),
|
||||
child: const Text('確定'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _handleLogout(AuthProvider auth) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
showDialog(
|
||||
@@ -123,12 +91,6 @@ class _MinePageState extends State<MinePage>
|
||||
onPressed: () async {
|
||||
Navigator.of(ctx).pop();
|
||||
await auth.logout();
|
||||
if (ctx.mounted) {
|
||||
Navigator.of(ctx).pushAndRemoveUntil(
|
||||
MaterialPageRoute(builder: (_) => const LoginPage()),
|
||||
(route) => false,
|
||||
);
|
||||
}
|
||||
},
|
||||
child: Text('退出', style: TextStyle(color: colorScheme.error)),
|
||||
|
||||
|
||||
@@ -113,27 +113,15 @@ class _WelfareCenterPageState extends State<WelfareCenterPage> {
|
||||
required VoidCallback? onPressed,
|
||||
Color? disabledBackgroundColor,
|
||||
}) {
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
height: 44,
|
||||
child: ElevatedButton(
|
||||
onPressed: onPressed,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: backgroundColor,
|
||||
foregroundColor: foregroundColor,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||
),
|
||||
disabledBackgroundColor:
|
||||
disabledBackgroundColor ?? backgroundColor.withValues(alpha: 0.3),
|
||||
disabledForegroundColor: foregroundColor.withValues(alpha: 0.7),
|
||||
),
|
||||
return Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: GestureDetector(
|
||||
onTap: onPressed,
|
||||
child: Text(
|
||||
text,
|
||||
style: AppTextStyles.headlineMedium(context).copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: foregroundColor,
|
||||
style: AppTextStyles.bodyMedium(context).copyWith(
|
||||
color: onPressed != null ? backgroundColor : context.colors.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -223,35 +211,30 @@ class _WelfareCenterPageState extends State<WelfareCenterPage> {
|
||||
Text(
|
||||
referralCode.isEmpty ? '暫無邀請碼' : referralCode,
|
||||
style: AppTextStyles.displayMedium(context).copyWith(
|
||||
fontSize: 24, // 明確設置為 24px
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: goldAccent,
|
||||
letterSpacing: 2,
|
||||
letterSpacing: 1.5,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 1,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 40,
|
||||
child: ElevatedButton(
|
||||
onPressed: referralCode.isEmpty
|
||||
? null
|
||||
: () {
|
||||
Clipboard.setData(ClipboardData(text: referralCode));
|
||||
ToastUtils.showSuccess('邀請碼已複製');
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: goldAccent,
|
||||
foregroundColor: Theme.of(context).colorScheme.onPrimary,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||
),
|
||||
disabledBackgroundColor: goldAccent.withValues(alpha: 0.4),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: GestureDetector(
|
||||
onTap: referralCode.isEmpty ? null : () {
|
||||
Clipboard.setData(ClipboardData(text: referralCode));
|
||||
ToastUtils.showSuccess('邀請碼已複製');
|
||||
},
|
||||
child: Text(
|
||||
'複製邀請碼',
|
||||
style: AppTextStyles.headlineMedium(context).copyWith(color: Theme.of(context).colorScheme.onPrimary),
|
||||
style: AppTextStyles.bodyMedium(context).copyWith(
|
||||
color: referralCode.isEmpty
|
||||
? context.colors.onSurfaceVariant
|
||||
: goldAccent,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -826,9 +809,9 @@ class _WelfareCenterPageState extends State<WelfareCenterPage> {
|
||||
style: AppTextStyles.headlineSmall(context),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_buildRuleItem('新用戶註冊完成實名認證獎勵 100 USDT'),
|
||||
_buildRuleItem('邀請好友充值每達 1000 USDT,獎勵 100 USDT'),
|
||||
_buildRuleItem('好友推廣的人充值每達 1000 USDT,額外獎勵 50 USDT'),
|
||||
_buildRuleItem('新用戶首次充值完成後可領取 100 USDT(一次性)'),
|
||||
_buildRuleItem('邀請好友累計充值每滿 1,000 USDT,獎勵 100 USDT(最多8次/人)'),
|
||||
_buildRuleItem('好友推廣的用戶充值每滿 1,000 USDT,額外獎勵 50 USDT(最多8次/人)'),
|
||||
_buildRuleItem('獎勵直接發放至資金賬戶'),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -6,6 +6,7 @@ import '../../../core/theme/app_spacing.dart';
|
||||
import '../../../core/theme/app_theme.dart';
|
||||
import '../../../core/theme/app_theme_extension.dart';
|
||||
import '../../../providers/asset_provider.dart';
|
||||
import '../../../data/models/order_models.dart';
|
||||
import 'fund_orders_list.dart';
|
||||
|
||||
/// 訂單管理頁面
|
||||
@@ -29,7 +30,9 @@ class _OrdersPageState extends State<OrdersPage> with AutomaticKeepAliveClientMi
|
||||
}
|
||||
|
||||
void _loadData() {
|
||||
context.read<AssetProvider>().refreshAll();
|
||||
final provider = context.read<AssetProvider>();
|
||||
provider.refreshAll();
|
||||
provider.loadTradeOrders();
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -138,16 +141,151 @@ class TradeOrdersList extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final orders = provider.tradeOrders;
|
||||
|
||||
return Center(
|
||||
if (orders.isEmpty) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(AppSpacing.xl),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(LucideIcons.receipt, size: 48, color: theme.colorScheme.onSurfaceVariant),
|
||||
SizedBox(height: AppSpacing.sm + AppSpacing.xs),
|
||||
Text('暫無交易記錄', style: AppTextStyles.bodyMedium(context).copyWith(color: theme.colorScheme.onSurfaceVariant)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: () => provider.loadTradeOrders(),
|
||||
color: theme.colorScheme.primary,
|
||||
child: ListView.separated(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
shrinkWrap: true,
|
||||
padding: AppSpacing.pagePadding,
|
||||
itemCount: orders.length,
|
||||
separatorBuilder: (_, __) => Divider(color: theme.colorScheme.outline, height: 1),
|
||||
itemBuilder: (context, index) {
|
||||
final order = orders[index];
|
||||
return _TradeOrderCard(order: order);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TradeOrderCard extends StatelessWidget {
|
||||
final OrderTrade order;
|
||||
|
||||
const _TradeOrderCard({required this.order});
|
||||
|
||||
Color _directionColor() => order.isBuy ? AppColorScheme.up : AppColorScheme.down;
|
||||
|
||||
Color _statusColor() {
|
||||
switch (order.status) {
|
||||
case 0: return AppColorScheme.warning;
|
||||
case 1: return AppColorScheme.success;
|
||||
case 2: return AppColorScheme.error;
|
||||
case 3: return AppColorScheme.muted;
|
||||
default: return AppColorScheme.muted;
|
||||
}
|
||||
}
|
||||
|
||||
String _formatTime(DateTime? time) {
|
||||
if (time == null) return '';
|
||||
return '${time.year}-${time.month.toString().padLeft(2, '0')}-${time.day.toString().padLeft(2, '0')} '
|
||||
'${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final dirColor = _directionColor();
|
||||
final statusColor = _statusColor();
|
||||
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(AppSpacing.xl),
|
||||
padding: AppSpacing.cardPadding,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(LucideIcons.receipt, size: 48, color: theme.colorScheme.onSurfaceVariant),
|
||||
SizedBox(height: AppSpacing.sm + AppSpacing.xs),
|
||||
Text('暫無交易記錄', style: AppTextStyles.bodyMedium(context).copyWith(color: theme.colorScheme.onSurfaceVariant)),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: dirColor.withValues(alpha: 0.1),
|
||||
borderRadius: AppRadius.radiusSm,
|
||||
),
|
||||
child: Text(
|
||||
order.directionText,
|
||||
style: AppTextStyles.labelMedium(context).copyWith(color: dirColor, fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
order.coinCode,
|
||||
style: AppTextStyles.headlineMedium(context),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
if (order.orderType != null)
|
||||
Text(
|
||||
order.orderTypeText,
|
||||
style: AppTextStyles.bodySmall(context).copyWith(color: theme.colorScheme.onSurfaceVariant),
|
||||
),
|
||||
],
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: statusColor.withValues(alpha: 0.1),
|
||||
borderRadius: AppRadius.radiusSm,
|
||||
),
|
||||
child: Text(
|
||||
order.statusText,
|
||||
style: AppTextStyles.labelMedium(context).copyWith(color: statusColor),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text('價格', style: AppTextStyles.bodySmall(context).copyWith(color: theme.colorScheme.onSurfaceVariant)),
|
||||
Text('${order.price} USDT', style: AppTextStyles.numberSmall(context)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text('數量', style: AppTextStyles.bodySmall(context).copyWith(color: theme.colorScheme.onSurfaceVariant)),
|
||||
Text(order.quantity, style: AppTextStyles.numberSmall(context)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text('金額', style: AppTextStyles.bodySmall(context).copyWith(color: theme.colorScheme.onSurfaceVariant)),
|
||||
Text('${order.amount} USDT', style: AppTextStyles.numberSmall(context).copyWith(fontWeight: FontWeight.w600)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text('時間', style: AppTextStyles.bodySmall(context).copyWith(color: theme.colorScheme.onSurfaceVariant)),
|
||||
Text(_formatTime(order.createTime), style: AppTextStyles.bodySmall(context).copyWith(color: theme.colorScheme.onSurfaceVariant)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,73 +1,130 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../../core/theme/app_spacing.dart';
|
||||
import '../../../../core/theme/app_theme.dart';
|
||||
import '../../../../core/theme/app_theme_extension.dart';
|
||||
import '../../../../data/models/coin.dart';
|
||||
import '../../../components/coin_icon.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
||||
/// 價格卡片組件
|
||||
///
|
||||
/// 顯示當前幣種價格和 24h 漲跌幅。
|
||||
/// 佈局:大號價格(32px bold) + 漲跌幅徽章(圓角sm,漲綠背景) + "24h 變化" 副標題。
|
||||
/// 交易页顶部信息区:代币信息(左) + 价格信息(右) 同行展示
|
||||
class PriceCard extends StatelessWidget {
|
||||
final Coin coin;
|
||||
const PriceCard({super.key, required this.coin});
|
||||
final String tradingStatus;
|
||||
final VoidCallback? onTapCoin;
|
||||
|
||||
const PriceCard({
|
||||
super.key,
|
||||
required this.coin,
|
||||
this.tradingStatus = 'trading',
|
||||
this.onTapCoin,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final isUp = coin.isUp;
|
||||
final changeColor =
|
||||
isUp ? context.appColors.up : context.appColors.down;
|
||||
final changeBgColor = isUp
|
||||
? context.appColors.upBackground
|
||||
: context.appColors.downBackground;
|
||||
final changeColor = isUp ? colorScheme.tertiary : colorScheme.error;
|
||||
final isTrading = tradingStatus == 'trading';
|
||||
final isLunch = tradingStatus == 'lunch_break';
|
||||
final statusLabel = isTrading ? '交易中' : (isLunch ? '午休中' : '已收盘');
|
||||
final statusColor = isTrading ? colorScheme.tertiary : (isLunch ? Colors.orange : colorScheme.onSurfaceVariant);
|
||||
final showPrice = true;
|
||||
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(20), // 24px → 20px
|
||||
decoration: BoxDecoration(
|
||||
color: context.appColors.surfaceCard,
|
||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||
border: Border.all(
|
||||
color: context.appColors.ghostBorder,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
return Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: AppSpacing.md, vertical: AppSpacing.sm),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
// 價格行:大號價格 + 漲跌幅徽章
|
||||
Row(
|
||||
// 左侧:代币信息
|
||||
GestureDetector(
|
||||
onTap: onTapCoin,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
CoinIcon(symbol: coin.code, size: 28),
|
||||
const SizedBox(width: 8),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
coin.code,
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700, color: colorScheme.onSurface),
|
||||
),
|
||||
Text(
|
||||
' /USDT',
|
||||
style: TextStyle(fontSize: 12, color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
const SizedBox(width: 2),
|
||||
Icon(LucideIcons.chevronDown, size: 12, color: colorScheme.onSurfaceVariant),
|
||||
],
|
||||
),
|
||||
// 交易状态
|
||||
Text(
|
||||
statusLabel,
|
||||
style: TextStyle(fontSize: 11, color: statusColor, fontWeight: FontWeight.w500),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
// 右侧:价格 + 涨跌幅
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
coin.formattedPrice,
|
||||
style: AppTextStyles.numberLarge(context).copyWith(fontSize: 32),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
// 漲跌幅徽章 - 圓角sm,漲綠背景
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8, vertical: 4), // 調整 padding
|
||||
decoration: BoxDecoration(
|
||||
color: changeBgColor,
|
||||
borderRadius: BorderRadius.circular(AppRadius.sm),
|
||||
),
|
||||
child: Text(
|
||||
coin.formattedChange,
|
||||
style: AppTextStyles.numberSmall(context).copyWith(
|
||||
color: changeColor,
|
||||
fontWeight: FontWeight.w600,
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.baseline,
|
||||
textBaseline: TextBaseline.alphabetic,
|
||||
children: [
|
||||
Text(
|
||||
showPrice ? coin.formattedPrice : '--',
|
||||
style: TextStyle(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: showPrice ? colorScheme.onSurface : colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (showPrice) ...[
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
coin.formattedChange,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: changeColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
// 24h 统计 — 小字一行
|
||||
Text(
|
||||
'高 ${_fmt(coin.high24h)} 低 ${_fmt(coin.low24h)} 量 ${_fmtVol(coin.volume24h)}',
|
||||
style: TextStyle(fontSize: 10, color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
// 副標題
|
||||
Text(
|
||||
'24h 變化',
|
||||
style: AppTextStyles.bodySmall(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _fmt(double? v) {
|
||||
if (v == null) return '--';
|
||||
if (v >= 1000) return v.toStringAsFixed(2);
|
||||
if (v >= 1) return v.toStringAsFixed(4);
|
||||
return v.toStringAsFixed(6);
|
||||
}
|
||||
|
||||
String _fmtVol(double? v) {
|
||||
if (v == null) return '--';
|
||||
if (v >= 1000000) return '${(v / 1000000).toStringAsFixed(2)}M';
|
||||
if (v >= 1000) return '${(v / 1000).toStringAsFixed(2)}K';
|
||||
return v.toStringAsFixed(2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,29 +1,26 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../../core/theme/app_spacing.dart';
|
||||
import '../../../core/theme/app_theme.dart';
|
||||
import '../../../core/theme/app_theme_extension.dart';
|
||||
import '../../../core/event/app_event_bus.dart';
|
||||
import '../../../data/models/coin.dart';
|
||||
import '../../../data/models/order_models.dart';
|
||||
import '../../../providers/market_provider.dart';
|
||||
import '../../../providers/asset_provider.dart';
|
||||
import '../../../providers/trade_provider.dart';
|
||||
import '../../../data/services/trade_service.dart';
|
||||
import '../../components/neon_glow.dart';
|
||||
import 'components/coin_selector.dart';
|
||||
import '../../components/coin_icon.dart';
|
||||
import 'components/price_card.dart';
|
||||
import 'components/placeholder_card.dart';
|
||||
import 'components/trade_form_card.dart';
|
||||
import 'components/trade_button.dart';
|
||||
import 'components/split_trade_form.dart';
|
||||
import 'components/confirm_dialog.dart';
|
||||
import 'trade_history_page.dart';
|
||||
|
||||
/// 交易頁面
|
||||
/// 交易頁面 — 幣安級佈局
|
||||
///
|
||||
/// 設計稿 Trade 頁面,佈局結構:
|
||||
/// - 幣種選擇器卡片(Coin Selector Card)
|
||||
/// - 價格卡片(Price Card):大號價格 + 漲跌幅徽章 + 副標題
|
||||
/// - 買入/賣出切換(Buy/Sell Toggle)
|
||||
/// - 交易表單卡片(Trade Form Card):金額輸入 + 快捷比例 + 計算數量
|
||||
/// - CTA 買入/賣出按鈕(Buy/Sell Button)
|
||||
/// Header → 實時價格 → 左表單+右訂單簿 → 底部(當前委託/持有幣種)
|
||||
class TradePage extends StatefulWidget {
|
||||
final String? initialCoinCode;
|
||||
|
||||
@@ -34,11 +31,21 @@ class TradePage extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _TradePageState extends State<TradePage>
|
||||
with AutomaticKeepAliveClientMixin {
|
||||
int _tradeType = 0; // 0=買入, 1=賣出
|
||||
with AutomaticKeepAliveClientMixin, SingleTickerProviderStateMixin {
|
||||
Coin? _selectedCoin;
|
||||
final _amountController = TextEditingController();
|
||||
bool _isSubmitting = false;
|
||||
bool _isBuySubmitting = false;
|
||||
bool _isSellSubmitting = false;
|
||||
int _orderType = 1;
|
||||
double _realtimePrice = 0;
|
||||
|
||||
final _buyPriceController = TextEditingController();
|
||||
final _buyQuantityController = TextEditingController();
|
||||
final _sellPriceController = TextEditingController();
|
||||
final _sellQuantityController = TextEditingController();
|
||||
|
||||
late TabController _bottomTabController;
|
||||
List<OrderTrade> _pendingOrders = [];
|
||||
bool _isLoadingOrders = false;
|
||||
|
||||
@override
|
||||
bool get wantKeepAlive => true;
|
||||
@@ -46,196 +53,689 @@ class _TradePageState extends State<TradePage>
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_bottomTabController = TabController(length: 2, vsync: this);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _loadData());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_buyPriceController.dispose();
|
||||
_buyQuantityController.dispose();
|
||||
_sellPriceController.dispose();
|
||||
_sellQuantityController.dispose();
|
||||
_bottomTabController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _loadData() {
|
||||
final marketProvider = context.read<MarketProvider>();
|
||||
marketProvider.loadCoins().then((_) {
|
||||
if (widget.initialCoinCode != null && _selectedCoin == null) {
|
||||
final coins = marketProvider.allCoins;
|
||||
final coin = coins.firstWhere(
|
||||
(c) =>
|
||||
c.code.toUpperCase() == widget.initialCoinCode!.toUpperCase(),
|
||||
orElse: () =>
|
||||
coins.isNotEmpty ? coins.first : throw Exception('No coins'),
|
||||
if (_selectedCoin != null) return;
|
||||
|
||||
final coins = marketProvider.allCoins;
|
||||
if (coins.isEmpty) return;
|
||||
|
||||
Coin? coin;
|
||||
if (widget.initialCoinCode != null) {
|
||||
coin = coins.firstWhere(
|
||||
(c) => c.code.toUpperCase() == widget.initialCoinCode!.toUpperCase(),
|
||||
orElse: () => coins.first,
|
||||
);
|
||||
if (mounted) setState(() => _selectedCoin = coin);
|
||||
} else {
|
||||
// 默认选中第一个平台代币
|
||||
final platformCoins = marketProvider.platformCoins;
|
||||
coin = platformCoins.isNotEmpty ? platformCoins.first : coins.first;
|
||||
}
|
||||
if (mounted) _selectCoin(coin);
|
||||
});
|
||||
context.read<AssetProvider>().refreshAll(force: true);
|
||||
final assetProvider = context.read<AssetProvider>();
|
||||
if (assetProvider.holdings.isEmpty) {
|
||||
assetProvider.refreshAll(force: true);
|
||||
}
|
||||
_loadPendingOrders();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_amountController.dispose();
|
||||
super.dispose();
|
||||
void _selectCoin(Coin coin) {
|
||||
setState(() {
|
||||
_selectedCoin = coin;
|
||||
_buyQuantityController.clear();
|
||||
_sellQuantityController.clear();
|
||||
});
|
||||
context.read<TradeProvider>().selectCoin(coin);
|
||||
}
|
||||
|
||||
void _showCoinPicker(BuildContext context, List<Coin> coins) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: Colors.transparent,
|
||||
isScrollControlled: true,
|
||||
builder: (ctx) => Container(
|
||||
height: MediaQuery.of(ctx).size.height * 0.5,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(ctx).colorScheme.surface,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(AppRadius.xxl)),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
margin: const EdgeInsets.only(top: AppSpacing.sm),
|
||||
width: 40,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(ctx).colorScheme.onSurfaceVariant.withValues(alpha: 0.3),
|
||||
borderRadius: BorderRadius.circular(AppRadius.sm),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
child: Text('选择币种', style: AppTextStyles.headlineLarge(context)),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
itemCount: coins.length,
|
||||
itemBuilder: (_, index) {
|
||||
final c = coins[index];
|
||||
final selected = c.code == _selectedCoin?.code;
|
||||
return ListTile(
|
||||
leading: CoinIcon(symbol: c.code, size: 28),
|
||||
title: Text('${c.code}/USDT', style: const TextStyle(fontWeight: FontWeight.w600)),
|
||||
subtitle: Text(c.name, style: TextStyle(fontSize: 12, color: Theme.of(context).colorScheme.onSurfaceVariant)),
|
||||
trailing: selected ? Icon(Icons.check, color: Theme.of(context).colorScheme.primary) : null,
|
||||
selectedTileColor: Theme.of(context).colorScheme.primary.withValues(alpha: 0.05),
|
||||
selected: selected,
|
||||
onTap: () { Navigator.of(ctx).pop(); _selectCoin(c); },
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _loadPendingOrders() async {
|
||||
if (_isLoadingOrders) return;
|
||||
setState(() => _isLoadingOrders = true);
|
||||
try {
|
||||
final response = await context.read<TradeService>().getOrders(
|
||||
coinCode: _selectedCoin?.code,
|
||||
pageNum: 1,
|
||||
pageSize: 50,
|
||||
);
|
||||
if (response.success && response.data != null) {
|
||||
final list = response.data!['list'] as List? ?? [];
|
||||
setState(() {
|
||||
_pendingOrders = list
|
||||
.map((e) => OrderTrade.fromJson(e as Map<String, dynamic>))
|
||||
.where((o) => o.status == 0)
|
||||
.toList();
|
||||
});
|
||||
}
|
||||
} catch (_) {
|
||||
} finally {
|
||||
if (mounted) setState(() => _isLoadingOrders = false);
|
||||
}
|
||||
}
|
||||
|
||||
/// 獲取交易賬戶中 USDT 可用餘額
|
||||
String get _availableUsdt {
|
||||
final holdings = context.read<AssetProvider>().holdings;
|
||||
final usdt = holdings.where((h) => h.coinCode == 'USDT').firstOrNull;
|
||||
return usdt?.quantity ?? '0';
|
||||
}
|
||||
|
||||
/// 獲取交易賬戶中當前幣種的持倉數量
|
||||
String get _availableCoinQty {
|
||||
if (_selectedCoin == null) return '0';
|
||||
final holdings = context.read<AssetProvider>().holdings;
|
||||
final pos = holdings
|
||||
.where((h) => h.coinCode == _selectedCoin!.code)
|
||||
.firstOrNull;
|
||||
final pos = holdings.where((h) => h.coinCode == _selectedCoin!.code).firstOrNull;
|
||||
return pos?.quantity ?? '0';
|
||||
}
|
||||
|
||||
/// 計算可買入/賣出的最大 USDT 金額
|
||||
String get _maxAmount {
|
||||
if (_selectedCoin == null) return '0';
|
||||
final price = _selectedCoin!.price;
|
||||
if (price <= 0) return '0';
|
||||
|
||||
if (_tradeType == 0) {
|
||||
return _availableUsdt;
|
||||
} else {
|
||||
// 賣出:qty * price 截斷到2位
|
||||
final qty = double.tryParse(_availableCoinQty) ?? 0;
|
||||
return ((qty * price * 100).truncateToDouble() / 100).toStringAsFixed(2);
|
||||
}
|
||||
}
|
||||
|
||||
/// 計算數量(向下截斷到4位小數,確保 price * quantity <= amount)
|
||||
String get _calculatedQuantity {
|
||||
final amount = double.tryParse(_amountController.text) ?? 0;
|
||||
final price = _selectedCoin?.price ?? 0;
|
||||
if (price <= 0 || amount <= 0) return '0';
|
||||
// 使用與後端一致的截斷邏輯:先算原始數量,截斷到4位,再回算金額確保不超
|
||||
final rawQty = amount / price;
|
||||
final truncatedQty = (rawQty * 10000).truncateToDouble() / 10000;
|
||||
// 回算:roundedPrice * truncatedQty,確保不超過 amount
|
||||
final roundedPrice = (price * 100).truncateToDouble() / 100;
|
||||
if (roundedPrice * truncatedQty > amount) {
|
||||
// 回退一個最小單位(0.0001)
|
||||
return (truncatedQty - 0.0001).toStringAsFixed(4);
|
||||
}
|
||||
return truncatedQty.toStringAsFixed(4);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
super.build(context);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||
body: Consumer2<MarketProvider, AssetProvider>(
|
||||
builder: (context, market, asset, _) {
|
||||
return SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
AppSpacing.md, AppSpacing.md, AppSpacing.md, AppSpacing.xl + AppSpacing.sm, // 添加頂部間距
|
||||
backgroundColor: colorScheme.surface,
|
||||
body: Consumer3<MarketProvider, AssetProvider, TradeProvider>(
|
||||
builder: (context, market, asset, tradeProvider, _) {
|
||||
final filteredCoins = market.allCoins
|
||||
.where((c) => c.code != 'USDT' && c.code != 'BTC' && c.code != 'ETH')
|
||||
.toList();
|
||||
|
||||
// 从 TradeProvider 获取实时价格,更新到 controller
|
||||
_realtimePrice = tradeProvider.currentPrice;
|
||||
final realtimePrice = _realtimePrice;
|
||||
if (realtimePrice > 0 && _selectedCoin != null) {
|
||||
// 市价单:同步实时价到价格框
|
||||
if (_orderType == 1) {
|
||||
_buyPriceController.text = realtimePrice.toStringAsFixed(2);
|
||||
_sellPriceController.text = realtimePrice.toStringAsFixed(2);
|
||||
}
|
||||
// 只在价格变化时才更新 Coin 对象,减少 GC 压力
|
||||
if (_selectedCoin!.price != realtimePrice ||
|
||||
_selectedCoin!.change24h != tradeProvider.change24h) {
|
||||
_selectedCoin = Coin(
|
||||
id: _selectedCoin!.id,
|
||||
code: _selectedCoin!.code,
|
||||
name: _selectedCoin!.name,
|
||||
price: realtimePrice,
|
||||
priceType: _selectedCoin!.priceType,
|
||||
change24h: tradeProvider.change24h,
|
||||
high24h: tradeProvider.high24h,
|
||||
low24h: tradeProvider.low24h,
|
||||
volume24h: tradeProvider.volume24h,
|
||||
status: _selectedCoin!.status,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
// 顶部标题栏
|
||||
Container(
|
||||
height: 48,
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
'交易',
|
||||
style: AppTextStyles.headlineLarge(context).copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// 幣種選擇器卡片
|
||||
CoinSelector(
|
||||
selectedCoin: _selectedCoin,
|
||||
coins: market.allCoins
|
||||
.where((c) =>
|
||||
c.code != 'USDT' &&
|
||||
c.code != 'BTC' &&
|
||||
c.code != 'ETH')
|
||||
.toList(),
|
||||
onCoinSelected: (coin) {
|
||||
setState(() {
|
||||
_selectedCoin = coin;
|
||||
_amountController.clear();
|
||||
});
|
||||
},
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.only(
|
||||
left: AppSpacing.md,
|
||||
right: AppSpacing.md,
|
||||
top: AppSpacing.sm,
|
||||
bottom: AppSpacing.xl,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
child: Column(children: [
|
||||
if (_selectedCoin != null)
|
||||
PriceCard(
|
||||
coin: _selectedCoin!,
|
||||
tradingStatus: tradeProvider.tradingStatus,
|
||||
onTapCoin: () => _showCoinPicker(context, market.platformCoins),
|
||||
)
|
||||
else
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(AppSpacing.md),
|
||||
child: PlaceholderCard(message: '請先選擇交易幣種'),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
|
||||
// 價格卡片
|
||||
if (_selectedCoin != null)
|
||||
PriceCard(coin: _selectedCoin!)
|
||||
else
|
||||
PlaceholderCard(
|
||||
message: '請先選擇交易幣種',
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
if (_selectedCoin != null)
|
||||
SplitTradeForm(
|
||||
coin: _selectedCoin,
|
||||
depth: tradeProvider.depth,
|
||||
realtimePrice: realtimePrice,
|
||||
buyPriceController: _buyPriceController,
|
||||
buyQuantityController: _buyQuantityController,
|
||||
sellPriceController: _sellPriceController,
|
||||
sellQuantityController: _sellQuantityController,
|
||||
availableUsdt: _availableUsdt,
|
||||
availableCoinQty: _availableCoinQty,
|
||||
onBuyQuantityChanged: () => setState(() {}),
|
||||
onSellQuantityChanged: () => setState(() {}),
|
||||
onBuyFillPercent: _buyFillPercent,
|
||||
onSellFillPercent: _sellFillPercent,
|
||||
onBuySubmit: tradeProvider.isTradable && _canBuy() && !_isBuySubmitting
|
||||
? () => _executeTrade(isBuy: true) : null,
|
||||
onSellSubmit: tradeProvider.isTradable && _canSell() && !_isSellSubmitting
|
||||
? () => _executeTrade(isBuy: false) : null,
|
||||
orderType: _orderType,
|
||||
onOrderTypeChanged: (type) {
|
||||
setState(() {
|
||||
_orderType = type;
|
||||
if (type == 1 && realtimePrice > 0) {
|
||||
_buyPriceController.text = realtimePrice.toStringAsFixed(2);
|
||||
_sellPriceController.text = realtimePrice.toStringAsFixed(2);
|
||||
}
|
||||
});
|
||||
},
|
||||
)
|
||||
else
|
||||
const SizedBox.shrink(),
|
||||
|
||||
// 交易表單卡片(內含買入/賣出切換 + 表單)
|
||||
TradeFormCard(
|
||||
tradeType: _tradeType,
|
||||
selectedCoin: _selectedCoin,
|
||||
amountController: _amountController,
|
||||
availableUsdt: _availableUsdt,
|
||||
availableCoinQty: _availableCoinQty,
|
||||
calculatedQuantity: _calculatedQuantity,
|
||||
maxAmount: _maxAmount,
|
||||
onTradeTypeChanged: (type) => setState(() {
|
||||
_tradeType = type;
|
||||
_amountController.clear();
|
||||
}),
|
||||
onAmountChanged: () => setState(() {}),
|
||||
onFillPercent: (pct) => _fillPercent(pct),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
|
||||
// CTA 買入/賣出按鈕
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 48,
|
||||
child: TradeButton(
|
||||
isBuy: _tradeType == 0,
|
||||
coinCode: _selectedCoin?.code,
|
||||
enabled: _canTrade() && !_isSubmitting,
|
||||
isLoading: _isSubmitting,
|
||||
onPressed: _executeTrade,
|
||||
),
|
||||
),
|
||||
],
|
||||
if (_selectedCoin != null) ...[
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
_buildBottomSection(context, asset, tradeProvider),
|
||||
],
|
||||
]),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
bool _canTrade() {
|
||||
if (_selectedCoin == null) return false;
|
||||
final amount = double.tryParse(_amountController.text) ?? 0;
|
||||
if (amount <= 0) return false;
|
||||
// 買入時校驗不超過可用USDT
|
||||
if (_tradeType == 0) {
|
||||
final available = double.tryParse(_availableUsdt) ?? 0;
|
||||
if (amount > available) return false;
|
||||
}
|
||||
return true;
|
||||
// ==========================================
|
||||
// 底部:当前委托 / 持有币种
|
||||
// ==========================================
|
||||
|
||||
Widget _buildBottomSection(BuildContext context, AssetProvider asset, TradeProvider tradeProvider) {
|
||||
final holdingsCount = asset.holdings
|
||||
.where((h) => h.coinCode != 'USDT' && double.tryParse(h.quantity)?.compareTo(0) == 1)
|
||||
.length;
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: context.appColors.surfaceCard,
|
||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||
border: Border.all(color: context.appColors.ghostBorder),
|
||||
),
|
||||
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.sm),
|
||||
child: Row(children: [
|
||||
_buildTabLabel(context, '当前委托', 0, _pendingOrders.length),
|
||||
const SizedBox(width: 16),
|
||||
_buildTabLabel(context, '持有币种', 1, holdingsCount),
|
||||
const Spacer(),
|
||||
GestureDetector(
|
||||
onTap: () => _navigateToHistory(context),
|
||||
child: Icon(Icons.history, size: 18, color: context.colors.onSurfaceVariant),
|
||||
),
|
||||
]),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
SizedBox(
|
||||
height: 180,
|
||||
child: TabBarView(
|
||||
controller: _bottomTabController,
|
||||
children: [
|
||||
_buildPendingOrdersTab(context),
|
||||
_buildHoldingsTab(context, asset, tradeProvider),
|
||||
],
|
||||
),
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
void _fillPercent(double pct) {
|
||||
final max = double.tryParse(_maxAmount) ?? 0;
|
||||
final value = max * pct;
|
||||
Widget _buildTabLabel(BuildContext context, String label, int index, int count) {
|
||||
final isSelected = _bottomTabController.index == index;
|
||||
final colorScheme = context.colors;
|
||||
return GestureDetector(
|
||||
onTap: () { _bottomTabController.animateTo(index); setState(() {}); },
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: isSelected ? context.colors.primary : Colors.transparent,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: isSelected ? FontWeight.w700 : FontWeight.w500,
|
||||
color: isSelected ? context.colors.primary : colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
if (count > 0) ...[
|
||||
const SizedBox(width: 4),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? context.colors.primary.withValues(alpha: 0.15)
|
||||
: colorScheme.onSurfaceVariant.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text('$count',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: isSelected ? context.colors.primary : colorScheme.onSurfaceVariant,
|
||||
)),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (_tradeType == 0) {
|
||||
// 買入:向下截斷到2位小數
|
||||
_amountController.text =
|
||||
((value * 100).truncateToDouble() / 100).toStringAsFixed(2);
|
||||
} else {
|
||||
// 賣出:_maxAmount 已是 qty*price 的四捨五入值,直接截斷
|
||||
_amountController.text =
|
||||
((value * 100).truncateToDouble() / 100).toStringAsFixed(2);
|
||||
Widget _buildPendingOrdersTab(BuildContext context) {
|
||||
if (_isLoadingOrders) {
|
||||
return const Center(child: SizedBox(
|
||||
width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2),
|
||||
));
|
||||
}
|
||||
if (_pendingOrders.isEmpty) {
|
||||
return Center(child: Text('暂无委托订单',
|
||||
style: AppTextStyles.bodySmall(context).copyWith(color: context.colors.onSurfaceVariant)));
|
||||
}
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.sm, vertical: AppSpacing.xs),
|
||||
itemCount: _pendingOrders.length,
|
||||
itemBuilder: (context, index) {
|
||||
final order = _pendingOrders[index];
|
||||
final isBuy = order.isBuy;
|
||||
final dirColor = isBuy ? context.appColors.up : context.appColors.down;
|
||||
final dirText = isBuy ? '买入' : '卖出';
|
||||
final amount = (double.tryParse(order.price) ?? 0) * (double.tryParse(order.quantity) ?? 0);
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: context.colors.surfaceContainerHighest.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(AppRadius.sm),
|
||||
),
|
||||
child: Column(children: [
|
||||
// 第一行:方向+币种+类型 | 委托金额 | 撤销
|
||||
Row(children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(color: dirColor.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(4)),
|
||||
child: Text(dirText,
|
||||
style: AppTextStyles.bodySmall(context).copyWith(color: dirColor, fontWeight: FontWeight.w600)),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(order.coinCode,
|
||||
style: AppTextStyles.bodySmall(context).copyWith(fontWeight: FontWeight.w600)),
|
||||
const SizedBox(width: 4),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: context.colors.onSurfaceVariant.withValues(alpha: 0.2)),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
child: Text(order.orderTypeText,
|
||||
style: AppTextStyles.bodySmall(context).copyWith(fontSize: 9, color: context.colors.onSurfaceVariant)),
|
||||
),
|
||||
const Spacer(),
|
||||
Text('${amount.toStringAsFixed(2)} USDT',
|
||||
style: AppTextStyles.numberSmall(context).copyWith(fontWeight: FontWeight.w600)),
|
||||
const SizedBox(width: 8),
|
||||
GestureDetector(
|
||||
onTap: () => _cancelOrder(order.orderNo),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: context.colors.onSurfaceVariant.withValues(alpha: 0.3)),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text('撤销',
|
||||
style: AppTextStyles.bodySmall(context).copyWith(fontSize: 11)),
|
||||
),
|
||||
),
|
||||
]),
|
||||
const SizedBox(height: 8),
|
||||
// 第二行:委托价 | 数量 | 时间
|
||||
Row(children: [
|
||||
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text('委托价', style: AppTextStyles.bodySmall(context).copyWith(
|
||||
color: context.colors.onSurfaceVariant, fontSize: 10)),
|
||||
const SizedBox(height: 2),
|
||||
Text(order.price, style: AppTextStyles.numberSmall(context).copyWith(fontSize: 12)),
|
||||
])),
|
||||
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.center, children: [
|
||||
Text('数量', style: AppTextStyles.bodySmall(context).copyWith(
|
||||
color: context.colors.onSurfaceVariant, fontSize: 10)),
|
||||
const SizedBox(height: 2),
|
||||
Text(order.quantity, style: AppTextStyles.numberSmall(context).copyWith(fontSize: 12)),
|
||||
])),
|
||||
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.end, children: [
|
||||
Text('时间', style: AppTextStyles.bodySmall(context).copyWith(
|
||||
color: context.colors.onSurfaceVariant, fontSize: 10)),
|
||||
const SizedBox(height: 2),
|
||||
Text(_formatTime(order.createTime),
|
||||
style: AppTextStyles.bodySmall(context).copyWith(fontSize: 11)),
|
||||
])),
|
||||
]),
|
||||
]),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
String _formatTime(DateTime? time) {
|
||||
if (time == null) return '--';
|
||||
return '${time.month.toString().padLeft(2, '0')}-${time.day.toString().padLeft(2, '0')} '
|
||||
'${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
Widget _buildHoldingsTab(BuildContext context, AssetProvider asset, TradeProvider tradeProvider) {
|
||||
final holdings = asset.holdings
|
||||
.where((h) => h.coinCode != 'USDT' && double.tryParse(h.quantity)?.compareTo(0) == 1)
|
||||
.toList();
|
||||
|
||||
if (holdings.isEmpty) {
|
||||
return Center(child: Text('暂无持仓',
|
||||
style: AppTextStyles.bodySmall(context).copyWith(color: context.colors.onSurfaceVariant)));
|
||||
}
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.sm, vertical: AppSpacing.xs),
|
||||
itemCount: holdings.length,
|
||||
itemBuilder: (context, index) {
|
||||
final h = holdings[index];
|
||||
final qty = double.tryParse(h.quantity) ?? 0;
|
||||
final avgPrice = double.tryParse(h.avgPrice) ?? 0;
|
||||
final cost = avgPrice * qty;
|
||||
|
||||
// 判断是否平台代币,获取今日盈利比率
|
||||
final coinInfo = context.read<MarketProvider>().getCoinByCode(h.coinCode);
|
||||
final isPlatform = coinInfo?.isPlatform == 1;
|
||||
final todayRate = coinInfo?.todayProfitRate ?? 0.005;
|
||||
|
||||
double value;
|
||||
double profit;
|
||||
double displayPrice;
|
||||
if (isPlatform) {
|
||||
// 平台代币:未实现盈亏 = 成本 × 今日盈利比率
|
||||
profit = cost * todayRate;
|
||||
value = cost + profit;
|
||||
displayPrice = avgPrice * (1 + todayRate);
|
||||
} else {
|
||||
displayPrice = tradeProvider.currentPrice > 0 && h.coinCode == _selectedCoin?.code
|
||||
? tradeProvider.currentPrice : avgPrice;
|
||||
value = displayPrice * qty;
|
||||
profit = value - cost;
|
||||
}
|
||||
final profitRate = cost > 0 ? (profit / cost) * 100 : 0.0;
|
||||
final isProfit = profit >= 0;
|
||||
final rateColor = isProfit ? context.appColors.up : context.appColors.down;
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: context.colors.surfaceContainerHighest.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(AppRadius.sm),
|
||||
),
|
||||
child: Column(children: [
|
||||
// 第一行:币种 | 卖出 | 盈亏%
|
||||
Row(children: [
|
||||
Text(h.coinCode,
|
||||
style: AppTextStyles.bodySmall(context).copyWith(fontWeight: FontWeight.w700, fontSize: 14)),
|
||||
const Spacer(),
|
||||
GestureDetector(
|
||||
onTap: () => _quickSell(h.coinCode, h.quantity, displayPrice),
|
||||
child: Text('卖出',
|
||||
style: AppTextStyles.bodySmall(context).copyWith(
|
||||
color: context.appColors.down, fontSize: 12)),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(color: rateColor.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(4)),
|
||||
child: Text('${isProfit ? '+' : ''}${profitRate.toStringAsFixed(2)}%',
|
||||
style: AppTextStyles.bodySmall(context).copyWith(
|
||||
color: rateColor, fontWeight: FontWeight.w700, fontSize: 13)),
|
||||
),
|
||||
]),
|
||||
const SizedBox(height: 8),
|
||||
// 第二行:持有 | 买入均价 | 持仓成本
|
||||
Row(children: [
|
||||
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text('持有', style: AppTextStyles.bodySmall(context).copyWith(
|
||||
color: context.colors.onSurfaceVariant, fontSize: 10)),
|
||||
const SizedBox(height: 2),
|
||||
Text(_truncate4(qty), style: AppTextStyles.numberSmall(context).copyWith(fontSize: 12)),
|
||||
])),
|
||||
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.center, children: [
|
||||
Text('均价', style: AppTextStyles.bodySmall(context).copyWith(
|
||||
color: context.colors.onSurfaceVariant, fontSize: 10)),
|
||||
const SizedBox(height: 2),
|
||||
Text(_truncate4(avgPrice), style: AppTextStyles.numberSmall(context).copyWith(fontSize: 12)),
|
||||
])),
|
||||
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.end, children: [
|
||||
Text('持仓成本', style: AppTextStyles.bodySmall(context).copyWith(
|
||||
color: context.colors.onSurfaceVariant, fontSize: 10)),
|
||||
const SizedBox(height: 2),
|
||||
Text('${cost.toStringAsFixed(2)} U', style: AppTextStyles.numberSmall(context).copyWith(
|
||||
fontSize: 12, fontWeight: FontWeight.w600)),
|
||||
])),
|
||||
]),
|
||||
const SizedBox(height: 6),
|
||||
// 第三行:现价/预估价 | 未实现盈亏
|
||||
Row(children: [
|
||||
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(isPlatform ? '预估价' : '现价', style: AppTextStyles.bodySmall(context).copyWith(
|
||||
color: context.colors.onSurfaceVariant, fontSize: 10)),
|
||||
const SizedBox(height: 2),
|
||||
Text(_truncate4(displayPrice), style: AppTextStyles.numberSmall(context).copyWith(fontSize: 12)),
|
||||
])),
|
||||
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.end, children: [
|
||||
Text('未实现盈亏', style: AppTextStyles.bodySmall(context).copyWith(
|
||||
color: context.colors.onSurfaceVariant, fontSize: 10)),
|
||||
const SizedBox(height: 2),
|
||||
Text('${isProfit ? '+' : ''}${profit.toStringAsFixed(2)} U',
|
||||
style: AppTextStyles.numberSmall(context).copyWith(
|
||||
fontSize: 12, color: rateColor, fontWeight: FontWeight.w600)),
|
||||
])),
|
||||
]),
|
||||
]),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
String _truncate4(double v) => (v * 10000).truncate() / 10000 < 0.0001
|
||||
? '0.0000' : ((v * 10000).truncate() / 10000).toStringAsFixed(4);
|
||||
|
||||
Future<void> _quickSell(String coinCode, String quantity, double price) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => ConfirmDialog(
|
||||
isBuy: false,
|
||||
coinCode: coinCode,
|
||||
price: price.toStringAsFixed(2),
|
||||
quantity: quantity,
|
||||
amount: (price * (double.tryParse(quantity) ?? 0)).toStringAsFixed(2),
|
||||
),
|
||||
);
|
||||
if (confirmed != true) return;
|
||||
|
||||
setState(() => _isSellSubmitting = true);
|
||||
try {
|
||||
final response = await context.read<TradeService>().sell(
|
||||
coinCode: coinCode,
|
||||
price: price.toStringAsFixed(2),
|
||||
quantity: quantity,
|
||||
orderType: 1,
|
||||
);
|
||||
if (!mounted) return;
|
||||
if (response.success) {
|
||||
context.read<AssetProvider>().refreshAll(force: true);
|
||||
context.read<AppEventBus>().fire(AppEventType.assetChanged);
|
||||
_loadPendingOrders();
|
||||
_showResultDialog(true, '賣出成功', '市價賣出 $quantity $coinCode');
|
||||
} else {
|
||||
_showResultDialog(false, '交易失敗', response.message ?? '請稍後重試');
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) _showResultDialog(false, '交易失敗', e.toString());
|
||||
} finally {
|
||||
if (mounted) setState(() => _isSellSubmitting = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _navigateToHistory(BuildContext context) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => TradeHistoryPage(coinCode: _selectedCoin?.code)),
|
||||
).then((_) {
|
||||
// 返回时刷新委托列表
|
||||
_loadPendingOrders();
|
||||
context.read<AssetProvider>().refreshAll(force: true);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _cancelOrder(String orderNo) async {
|
||||
try {
|
||||
final response = await context.read<TradeService>().cancelOrder(orderNo);
|
||||
if (mounted) {
|
||||
if (response.success) {
|
||||
_loadPendingOrders();
|
||||
context.read<AssetProvider>().refreshAll(force: true);
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 交易逻辑
|
||||
// ============================================
|
||||
|
||||
bool _canBuy() {
|
||||
if (_selectedCoin == null) return false;
|
||||
final qty = double.tryParse(_buyQuantityController.text) ?? 0;
|
||||
if (qty <= 0) return false;
|
||||
final price = double.tryParse(_buyPriceController.text) ?? 0;
|
||||
final amount = price * qty;
|
||||
final available = double.tryParse(_availableUsdt) ?? 0;
|
||||
return amount <= available;
|
||||
}
|
||||
|
||||
bool _canSell() {
|
||||
if (_selectedCoin == null) return false;
|
||||
final qty = double.tryParse(_sellQuantityController.text) ?? 0;
|
||||
if (qty <= 0) return false;
|
||||
final available = double.tryParse(_availableCoinQty) ?? 0;
|
||||
return qty <= available;
|
||||
}
|
||||
|
||||
void _buyFillPercent(double pct) {
|
||||
final price = _orderType == 1 ? _realtimePrice : (double.tryParse(_buyPriceController.text) ?? 0);
|
||||
final available = double.tryParse(_availableUsdt) ?? 0;
|
||||
if (price <= 0) return;
|
||||
// 100%时留极小余量防精度误差
|
||||
final safePct = pct >= 1.0 ? 0.9999 : pct;
|
||||
final qty = (available / price) * safePct;
|
||||
_buyQuantityController.text = qty < 0.0001 ? '' : ((qty * 10000).truncateToDouble() / 10000).toStringAsFixed(4);
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
void _executeTrade() async {
|
||||
final isBuy = _tradeType == 0;
|
||||
final amount = _amountController.text;
|
||||
final quantity = _calculatedQuantity;
|
||||
final price = _selectedCoin!.price.toStringAsFixed(2);
|
||||
void _sellFillPercent(double pct) {
|
||||
final available = double.tryParse(_availableCoinQty) ?? 0;
|
||||
final qty = available * pct;
|
||||
_sellQuantityController.text = (qty * 10000).truncateToDouble() / 10000 < 0.0001
|
||||
? '' : ((qty * 10000).truncateToDouble() / 10000).toStringAsFixed(4);
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
void _executeTrade({required bool isBuy}) async {
|
||||
final priceController = isBuy ? _buyPriceController : _sellPriceController;
|
||||
final qtyController = isBuy ? _buyQuantityController : _sellQuantityController;
|
||||
|
||||
final price = priceController.text;
|
||||
final quantity = qtyController.text;
|
||||
final coinCode = _selectedCoin!.code;
|
||||
final typeLabel = _orderType == 1 ? '市价单' : '限价单';
|
||||
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
@@ -244,41 +744,46 @@ class _TradePageState extends State<TradePage>
|
||||
coinCode: coinCode,
|
||||
price: price,
|
||||
quantity: quantity,
|
||||
amount: amount,
|
||||
amount: ((double.tryParse(price) ?? 0) * (double.tryParse(quantity) ?? 0)).toStringAsFixed(2),
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed != true) return;
|
||||
|
||||
setState(() => _isSubmitting = true);
|
||||
setState(() {
|
||||
if (isBuy) _isBuySubmitting = true;
|
||||
else _isSellSubmitting = true;
|
||||
});
|
||||
|
||||
try {
|
||||
final tradeService = context.read<TradeService>();
|
||||
final response = isBuy
|
||||
? await tradeService.buy(
|
||||
coinCode: coinCode, price: price, quantity: quantity)
|
||||
: await tradeService.sell(
|
||||
coinCode: coinCode, price: price, quantity: quantity);
|
||||
? await tradeService.buy(coinCode: coinCode, price: price, quantity: quantity, orderType: _orderType)
|
||||
: await tradeService.sell(coinCode: coinCode, price: price, quantity: quantity, orderType: _orderType);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
if (response.success) {
|
||||
_amountController.clear();
|
||||
// 刷新資產數據
|
||||
qtyController.clear();
|
||||
context.read<AssetProvider>().refreshAll(force: true);
|
||||
// 通知其他頁面刷新
|
||||
context.read<AppEventBus>().fire(AppEventType.assetChanged);
|
||||
_showResultDialog(true, '${isBuy ? '買入' : '賣出'}成功',
|
||||
'$quantity $coinCode @ $price USDT');
|
||||
_loadPendingOrders();
|
||||
final msg = _orderType == 2
|
||||
? '$typeLabel委托成功: $quantity $coinCode @ $price USDT'
|
||||
: '$typeLabel: $quantity $coinCode @ $price USDT';
|
||||
_showResultDialog(true, isBuy ? '買入成功' : '賣出成功', msg);
|
||||
} else {
|
||||
_showResultDialog(false, '交易失敗', response.message ?? '請稍後重試');
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
_showResultDialog(false, '交易失敗', e.toString());
|
||||
}
|
||||
if (mounted) _showResultDialog(false, '交易失敗', e.toString());
|
||||
} finally {
|
||||
if (mounted) setState(() => _isSubmitting = false);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
if (isBuy) _isBuySubmitting = false;
|
||||
else _isSellSubmitting = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -286,25 +791,15 @@ class _TradePageState extends State<TradePage>
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Row(
|
||||
children: [
|
||||
NeonIcon(
|
||||
icon: success ? Icons.check_circle : Icons.error,
|
||||
color: success
|
||||
? ctx.appColors.up
|
||||
: Theme.of(ctx).colorScheme.error,
|
||||
size: 24,
|
||||
),
|
||||
SizedBox(width: AppSpacing.sm),
|
||||
Text(title),
|
||||
],
|
||||
),
|
||||
title: Row(children: [
|
||||
NeonIcon(icon: success ? Icons.check_circle : Icons.error,
|
||||
color: success ? ctx.appColors.up : Theme.of(ctx).colorScheme.error, size: 24),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
Text(title),
|
||||
]),
|
||||
content: Text(message),
|
||||
actions: [
|
||||
TextButton(
|
||||
child: const Text('確定'),
|
||||
onPressed: () => Navigator.of(ctx).pop(),
|
||||
),
|
||||
TextButton(child: const Text('確定'), onPressed: () => Navigator.of(ctx).pop()),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user