This commit is contained in:
sion
2026-03-30 00:30:42 +08:00
parent 41c1288616
commit 2a901de2c3
27 changed files with 1324 additions and 650 deletions

View File

@@ -2,6 +2,7 @@
class ResponseCode {
static const String success = '0000';
static const String unauthorized = '0002';
static const String kycRequired = 'KYC_REQUIRED';
}
/// API 响应模型
@@ -71,4 +72,5 @@ class ApiResponse<T> {
bool get isSuccess => success;
bool get isUnauthorized => code == ResponseCode.unauthorized;
bool get isKycRequired => code == ResponseCode.kycRequired;
}

View File

@@ -68,6 +68,24 @@ class DioClient {
}
}
/// Multipart 文件上传
Future<ApiResponse<T>> upload<T>(
String path, {
required FormData formData,
T Function(dynamic)? fromJson,
}) async {
try {
final response = await _dio.post(
path,
data: formData,
options: Options(contentType: 'multipart/form-data'),
);
return _handleResponse<T>(response, fromJson);
} on DioException catch (e) {
return _handleError<T>(e);
}
}
ApiResponse<T> _handleResponse<T>(
Response response,
T Function(dynamic)? fromJson,

View File

@@ -1,3 +1,5 @@
import 'dart:typed_data';
import 'package:dio/dio.dart';
import '../../core/constants/api_endpoints.dart';
import '../../core/network/api_response.dart';
import '../../core/network/dio_client.dart';
@@ -39,14 +41,19 @@ class UserService {
);
}
/// 上传 KYC 资料
/// 上传 KYC 资料(身份证正反面图片字节)
/// 使用 fromBytes 以兼容 Web 和移动端
Future<ApiResponse<void>> uploadKyc(
String idCardFront,
String idCardBack,
Uint8List frontBytes,
Uint8List backBytes,
) async {
return _client.post<void>(
final formData = FormData.fromMap({
'front': MultipartFile.fromBytes(frontBytes, filename: 'front.jpg'),
'back': MultipartFile.fromBytes(backBytes, filename: 'back.jpg'),
});
return _client.upload<void>(
ApiEndpoints.kyc,
data: {'idCardFront': idCardFront, 'idCardBack': idCardBack},
formData: formData,
);
}

View File

@@ -1,3 +1,4 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import '../core/network/api_response.dart';
import '../core/network/dio_client.dart';
@@ -134,6 +135,21 @@ class AuthProvider extends ChangeNotifier {
}
}
/// 提交KYC实名认证真实图片上传
Future<ApiResponse<void>> submitKyc(
Uint8List frontBytes, Uint8List backBytes) async {
try {
final response =
await _userService.uploadKyc(frontBytes, backBytes);
if (response.success) {
await refreshUserInfo();
}
return response;
} catch (e) {
return ApiResponse.fail('KYC提交失败: $e');
}
}
void _setLoading(bool value) {
_isLoading = value;
notifyListeners();

View File

@@ -6,11 +6,13 @@ import 'package:google_fonts/google_fonts.dart';
import '../../../core/theme/app_color_scheme.dart';
import '../../../core/theme/app_spacing.dart';
import '../../../providers/asset_provider.dart';
import '../../../providers/auth_provider.dart';
import '../../shared/ui_constants.dart';
import '../../components/glass_panel.dart';
import '../../components/neon_glow.dart';
import '../orders/fund_orders_page.dart';
import 'transfer_page.dart';
import '../mine/kyc_page.dart';
/// 资产页面 - Material Design 3 风格
class AssetPage extends StatefulWidget {
@@ -897,6 +899,13 @@ class _WalletAddressCard extends StatelessWidget {
}
void _showWithdrawDialog(BuildContext context, String? balance) {
// KYC校验未完成实名认证则引导去认证
final auth = context.read<AuthProvider>();
if (auth.user?.kycStatus != 2) {
_showKycRequiredDialog(context);
return;
}
final amountController = TextEditingController();
final addressController = TextEditingController();
final contactController = TextEditingController();
@@ -1109,23 +1118,17 @@ void _showResultDialog(BuildContext context, String title, String? message) {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
title,
style: GoogleFonts.spaceGrotesk(
fontSize: 20,
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
Text(title,
style: GoogleFonts.spaceGrotesk(
fontSize: 20,
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
)),
if (message != null) ...[
SizedBox(height: AppSpacing.sm),
Text(
message,
style: TextStyle(
color: colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
Text(message,
style: TextStyle(color: colorScheme.onSurfaceVariant),
textAlign: TextAlign.center),
],
SizedBox(height: AppSpacing.lg),
SizedBox(
@@ -1144,3 +1147,85 @@ void _showResultDialog(BuildContext context, String title, String? message) {
),
);
}
void _showKycRequiredDialog(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
showShadDialog(
context: context,
builder: (ctx) => Dialog(
backgroundColor: Colors.transparent,
child: GlassPanel(
borderRadius: BorderRadius.circular(AppRadius.xxl),
padding: EdgeInsets.all(AppSpacing.lg),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding: EdgeInsets.all(AppSpacing.md),
decoration: BoxDecoration(
color: AppColorScheme.warning.withOpacity(0.1),
shape: BoxShape.circle,
),
child: Icon(
LucideIcons.shieldAlert,
color: AppColorScheme.warning,
size: 32,
),
),
SizedBox(height: AppSpacing.md),
Text(
'需要实名认证',
style: GoogleFonts.spaceGrotesk(
fontSize: 20,
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
SizedBox(height: AppSpacing.sm),
Text(
'提现前请先完成实名认证,保障账户安全',
style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontSize: 14,
),
textAlign: TextAlign.center,
),
SizedBox(height: AppSpacing.lg),
Row(
children: [
Expanded(
child: NeonButton(
text: '取消',
type: NeonButtonType.outline,
onPressed: () => Navigator.of(ctx).pop(),
height: 44,
showGlow: false,
),
),
SizedBox(width: AppSpacing.sm),
Expanded(
child: NeonButton(
text: '去认证',
type: NeonButtonType.primary,
onPressed: () {
Navigator.of(ctx).pop();
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const KycPage(returnToWithdraw: true),
),
);
},
height: 44,
showGlow: true,
),
),
],
),
],
),
),
),
);
}

View File

@@ -88,11 +88,11 @@ class _HomePageState extends State<HomePage>
),
SizedBox(height: AppSpacing.md),
// 新人福利卡片
if (!_bonusClaimed)
_BonusCard(
onClaim: _claimBonus,
isLoading: _bonusLoading,
),
_BonusCard(
isClaimed: _bonusClaimed,
onClaim: _claimBonus,
isLoading: _bonusLoading,
),
SizedBox(height: AppSpacing.lg),
// 持仓
_HoldingsSection(holdings: provider.holdings),
@@ -963,105 +963,118 @@ class _AssetCardState extends State<_AssetCard> {
/// 新人福利卡片
class _BonusCard extends StatelessWidget {
final bool isClaimed;
final VoidCallback onClaim;
final bool isLoading;
const _BonusCard({required this.onClaim, required this.isLoading});
const _BonusCard({required this.isClaimed, required this.onClaim, required this.isLoading});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final isDark = Theme.of(context).brightness == Brightness.dark;
return GestureDetector(
onTap: isLoading ? null : onClaim,
child: Container(
width: double.infinity,
padding: EdgeInsets.all(AppSpacing.lg),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
colorScheme.primary.withOpacity(0.15),
colorScheme.secondary.withOpacity(0.1),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
return Opacity(
opacity: isClaimed ? 0.6 : 1.0,
child: GestureDetector(
onTap: isClaimed || isLoading ? null : onClaim,
child: Container(
width: double.infinity,
padding: EdgeInsets.all(AppSpacing.lg),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
colorScheme.primary.withOpacity(0.15),
colorScheme.secondary.withOpacity(0.1),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(AppRadius.xl),
border: Border.all(color: colorScheme.primary.withOpacity(0.2)),
),
borderRadius: BorderRadius.circular(AppRadius.xl),
border: Border.all(color: colorScheme.primary.withOpacity(0.2)),
),
child: Row(
children: [
// 左侧图标
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: colorScheme.primary.withOpacity(0.15),
borderRadius: BorderRadius.circular(AppRadius.lg),
child: Row(
children: [
// 左侧图标
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: isClaimed
? colorScheme.onSurfaceVariant.withOpacity(0.1)
: colorScheme.primary.withOpacity(0.15),
borderRadius: BorderRadius.circular(AppRadius.lg),
),
child: Icon(
isClaimed ? LucideIcons.check : LucideIcons.gift,
color: isClaimed ? AppColorScheme.up : colorScheme.primary,
size: 24,
),
),
child: Icon(
LucideIcons.gift,
color: colorScheme.primary,
size: 24,
),
),
SizedBox(width: AppSpacing.md),
// 中间文字
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'新人福利',
style: GoogleFonts.spaceGrotesk(
fontSize: 16,
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
SizedBox(height: AppSpacing.xs),
Text(
'领取 50 USDT 体验金,开始您的交易之旅',
style: TextStyle(
fontSize: 12,
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
// 右侧按钮
Container(
padding: EdgeInsets.symmetric(
horizontal: AppSpacing.md,
vertical: AppSpacing.sm,
),
decoration: BoxDecoration(
gradient: isDark
? AppColorScheme.darkCtaGradient
: AppColorScheme.lightCtaGradient,
borderRadius: BorderRadius.circular(AppRadius.full),
),
child: isLoading
? SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
color: isDark ? colorScheme.background : Colors.white,
SizedBox(width: AppSpacing.md),
// 中间文字
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'新人福利',
style: GoogleFonts.spaceGrotesk(
fontSize: 16,
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
)
: Text(
'立即领取',
),
SizedBox(height: AppSpacing.xs),
Text(
isClaimed ? '50 USDT 体验金已到账' : '领取 50 USDT 体验金,开始您的交易之旅',
style: TextStyle(
color: isDark ? colorScheme.background : Colors.white,
fontSize: 13,
fontWeight: FontWeight.w700,
fontSize: 12,
color: colorScheme.onSurfaceVariant,
),
),
),
],
],
),
),
// 右侧按钮
Container(
padding: EdgeInsets.symmetric(
horizontal: AppSpacing.md,
vertical: AppSpacing.sm,
),
decoration: BoxDecoration(
color: isClaimed
? colorScheme.onSurfaceVariant.withOpacity(0.15)
: null,
gradient: isClaimed
? null
: (isDark
? AppColorScheme.darkCtaGradient
: AppColorScheme.lightCtaGradient),
borderRadius: BorderRadius.circular(AppRadius.full),
),
child: isLoading
? SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
color: isDark ? colorScheme.background : Colors.white,
),
)
: Text(
isClaimed ? '已领取' : '立即领取',
style: TextStyle(
color: isClaimed
? colorScheme.onSurfaceVariant
: (isDark ? colorScheme.background : Colors.white),
fontSize: 13,
fontWeight: FontWeight.w700,
),
),
),
],
),
),
),
);

View File

@@ -170,9 +170,7 @@ class _FeaturedCard extends StatelessWidget {
? AppColorScheme.getUpBackgroundColor(isDark)
: colorScheme.error.withOpacity(0.1);
return GestureDetector(
onTap: () => _navigateToTrade(context),
child: GlassPanel(
return GlassPanel(
padding: EdgeInsets.all(AppSpacing.lg),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -244,14 +242,8 @@ class _FeaturedCard extends StatelessWidget {
),
],
),
),
);
}
void _navigateToTrade(BuildContext context) {
final mainState = context.findAncestorStateOfType<MainPageState>();
mainState?.switchToTrade(coin.code);
}
}
/// 下半区列表项

View File

@@ -5,6 +5,7 @@ import 'package:google_fonts/google_fonts.dart';
import '../../../core/theme/app_color_scheme.dart';
import '../../../core/theme/app_spacing.dart';
import '../../../providers/auth_provider.dart';
import 'kyc_page.dart';
import '../../../providers/theme_provider.dart';
import '../auth/login_page.dart';
import '../../components/glass_panel.dart';
@@ -54,7 +55,11 @@ class _MinePageState extends State<MinePage> with AutomaticKeepAliveClientMixin
children: [
_UserCard(user: auth.user),
SizedBox(height: AppSpacing.md),
_MenuList(onShowComingSoon: _showComingSoon, onShowAbout: _showAboutDialog),
_MenuList(
onShowComingSoon: _showComingSoon,
onShowAbout: _showAboutDialog,
kycStatus: auth.user?.kycStatus ?? 0,
),
SizedBox(height: AppSpacing.xl),
_LogoutButton(onLogout: () => _handleLogout(auth)),
SizedBox(height: AppSpacing.lg),
@@ -324,8 +329,13 @@ class _InfoRow extends StatelessWidget {
class _MenuList extends StatelessWidget {
final void Function(String) onShowComingSoon;
final VoidCallback onShowAbout;
final int kycStatus;
const _MenuList({required this.onShowComingSoon, required this.onShowAbout});
const _MenuList({
required this.onShowComingSoon,
required this.onShowAbout,
required this.kycStatus,
});
@override
Widget build(BuildContext context) {
@@ -341,7 +351,7 @@ class _MenuList extends StatelessWidget {
_ThemeToggleTile(isDarkMode: themeProvider.isDarkMode),
_buildDivider(),
// 菜单项
..._buildMenuItems(colorScheme),
..._buildMenuItems(context, colorScheme),
],
),
);
@@ -355,14 +365,27 @@ class _MenuList extends StatelessWidget {
);
}
List<Widget> _buildMenuItems(ColorScheme colorScheme) {
List<Widget> _buildMenuItems(BuildContext context, ColorScheme colorScheme) {
final items = [
_MenuItem(
icon: LucideIcons.userCheck,
title: '实名认证',
subtitle: '完成实名认证,解锁更多功能',
iconColor: colorScheme.primary,
onTap: () => onShowComingSoon('实名认证'),
subtitle: kycStatus == 2
? '已认证'
: kycStatus == 1
? '审核中'
: '完成实名认证,解锁更多功能',
iconColor: kycStatus == 2 ? AppColorScheme.up : colorScheme.primary,
onTap: () {
if (kycStatus == 2) {
_showKycStatusDialog(context);
} else {
Navigator.push(
context,
MaterialPageRoute(builder: (_) => const KycPage()),
);
}
},
),
_MenuItem(
icon: LucideIcons.shield,
@@ -403,6 +426,28 @@ class _MenuList extends StatelessWidget {
}
}
void _showKycStatusDialog(BuildContext context) {
showShadDialog(
context: context,
builder: (ctx) => ShadDialog.alert(
title: Row(
children: [
Icon(Icons.check_circle, color: AppColorScheme.up, size: 20),
SizedBox(width: AppSpacing.sm),
const Text('实名认证'),
],
),
description: const Text('您的实名认证已通过'),
actions: [
ShadButton(
child: const Text('确定'),
onPressed: () => Navigator.of(ctx).pop(),
),
],
),
);
}
/// 主题切换组件
class _ThemeToggleTile extends StatelessWidget {
final bool isDarkMode;

View File

@@ -84,10 +84,8 @@ class _TradePageState extends State<TradePage>
if (price <= 0) return '0';
if (_tradeType == 0) {
// 买入:最大 = USDT 余额
return _availableUsdt;
} else {
// 卖出:最大 = 持有数量 × 当前价格
final qty = double.tryParse(_availableCoinQty) ?? 0;
return (qty * price).toStringAsFixed(2);
}
@@ -118,7 +116,10 @@ class _TradePageState extends State<TradePage>
_CoinSelector(
selectedCoin: _selectedCoin,
coins: market.allCoins
.where((c) => c.code != 'USDT')
.where((c) =>
c.code != 'USDT' &&
c.code != 'BTC' &&
c.code != 'ETH')
.toList(),
onCoinSelected: (coin) {
setState(() {
@@ -133,7 +134,7 @@ class _TradePageState extends State<TradePage>
if (_selectedCoin != null)
_PriceCard(coin: _selectedCoin!)
else
_PlaceholderCard(message: '请先选择交易币种'),
const _PlaceholderCard(message: '请先选择交易币种'),
SizedBox(height: AppSpacing.md),
@@ -197,7 +198,13 @@ class _TradePageState extends State<TradePage>
bool _canTrade() {
if (_selectedCoin == null) return false;
final amount = double.tryParse(_amountController.text) ?? 0;
return amount > 0;
if (amount <= 0) return false;
// 买入时校验不超过可用USDT
if (_tradeType == 0) {
final available = double.tryParse(_availableUsdt) ?? 0;
if (amount > available) return false;
}
return true;
}
void _fillPercent(double pct) {
@@ -524,22 +531,52 @@ class _CoinSelector extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 第一行:币种代码 + 价格 + 涨跌幅
Row(
children: [
Text(coin.code,
style: GoogleFonts.spaceGrotesk(
fontSize: 16,
fontSize: 15,
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
)),
SizedBox(width: AppSpacing.xs),
Text('/USDT',
style: TextStyle(
fontSize: 12,
fontSize: 11,
color: colorScheme.onSurfaceVariant,
)),
const Spacer(),
Text('\$${coin.formattedPrice}',
style: GoogleFonts.spaceGrotesk(
fontSize: 13,
fontWeight: FontWeight.w600,
color: colorScheme.onSurface,
)),
SizedBox(width: AppSpacing.sm),
Container(
padding: EdgeInsets.symmetric(
horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: changeColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(AppRadius.sm),
),
child: Text(coin.formattedChange,
style: TextStyle(
fontSize: 11,
color: changeColor,
fontWeight: FontWeight.w600,
)),
),
if (isSelected) ...[
SizedBox(width: AppSpacing.sm),
Icon(LucideIcons.check,
size: 16, color: colorScheme.primary),
],
],
),
SizedBox(height: 3),
// 第二行:币种名称
Text(coin.name,
style: TextStyle(
fontSize: 12,
@@ -548,27 +585,6 @@ class _CoinSelector extends StatelessWidget {
],
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text('\$${coin.formattedPrice}',
style: GoogleFonts.spaceGrotesk(
fontSize: 14,
fontWeight: FontWeight.w600,
color: colorScheme.onSurface,
)),
Text(coin.formattedChange,
style: TextStyle(
fontSize: 12,
color: changeColor,
fontWeight: FontWeight.w600,
)),
],
),
if (isSelected) ...[
SizedBox(width: AppSpacing.sm),
Icon(LucideIcons.check, size: 18, color: colorScheme.primary),
],
],
),
),
@@ -604,7 +620,7 @@ class _CoinAvatar extends StatelessWidget {
}
}
/// 价格卡片
/// 价格卡片 - 重新设计
class _PriceCard extends StatelessWidget {
final Coin coin;
const _PriceCard({required this.coin});
@@ -613,48 +629,81 @@ class _PriceCard extends StatelessWidget {
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final isDark = Theme.of(context).brightness == Brightness.dark;
final color =
coin.isUp ? AppColorScheme.getUpColor(isDark) : AppColorScheme.down;
final bgColor = coin.isUp
final isUp = coin.isUp;
final changeColor =
isUp ? AppColorScheme.getUpColor(isDark) : AppColorScheme.down;
final changeBgColor = isUp
? AppColorScheme.getUpBackgroundColor(isDark)
: colorScheme.error.withOpacity(0.1);
return GlassPanel(
padding: EdgeInsets.all(AppSpacing.lg),
padding: EdgeInsets.symmetric(
horizontal: AppSpacing.lg, vertical: AppSpacing.md + AppSpacing.sm),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('最新价',
style: TextStyle(
fontSize: 12,
color: colorScheme.onSurfaceVariant,
)),
SizedBox(height: AppSpacing.xs),
Text(
'\$${coin.formattedPrice}',
style: GoogleFonts.spaceGrotesk(
fontSize: 28,
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
// 左侧:币种标签 + 价格
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: EdgeInsets.symmetric(
horizontal: AppSpacing.sm, vertical: 2),
decoration: BoxDecoration(
color: colorScheme.primary.withOpacity(0.08),
borderRadius: BorderRadius.circular(AppRadius.sm),
),
child: Text(
'${coin.code}/USDT',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: colorScheme.primary,
),
),
),
],
),
),
],
SizedBox(height: AppSpacing.sm),
Text(
'\$${coin.formattedPrice}',
style: GoogleFonts.spaceGrotesk(
fontSize: 30,
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
],
),
),
// 右侧:涨跌幅
Container(
padding: EdgeInsets.symmetric(
horizontal: AppSpacing.md, vertical: AppSpacing.sm),
horizontal: AppSpacing.md, vertical: AppSpacing.sm + 2),
decoration: BoxDecoration(
color: bgColor,
borderRadius: BorderRadius.circular(AppRadius.md),
border: Border.all(color: color.withOpacity(0.2)),
color: changeBgColor,
borderRadius: BorderRadius.circular(AppRadius.lg),
border: Border.all(color: changeColor.withOpacity(0.15)),
),
child: Text(
coin.formattedChange,
style: TextStyle(
fontSize: 16, color: color, fontWeight: FontWeight.w700),
child: Column(
children: [
Text(
'24h',
style: TextStyle(
fontSize: 10,
color: changeColor.withOpacity(0.7),
fontWeight: FontWeight.w500,
),
),
SizedBox(height: 2),
Text(
coin.formattedChange,
style: TextStyle(
fontSize: 16, color: changeColor, fontWeight: FontWeight.w700),
),
],
),
),
],
@@ -684,7 +733,7 @@ class _PlaceholderCard extends StatelessWidget {
}
}
/// 交易表单卡片
/// 交易表单卡片 - 重新设计
class _TradeFormCard extends StatelessWidget {
final int tradeType;
final Coin? selectedCoin;
@@ -721,9 +770,9 @@ class _TradeFormCard extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 买入/卖出切换
// 买入/卖出切换 - 重新设计
Container(
padding: EdgeInsets.all(AppSpacing.xs),
padding: EdgeInsets.all(4),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerLowest,
borderRadius: BorderRadius.circular(AppRadius.xl),
@@ -731,11 +780,25 @@ class _TradeFormCard extends StatelessWidget {
child: Row(
children: [
Expanded(
child: _typeButton('买入', isBuy, AppColorScheme.up, () => onTradeTypeChanged(0)),
child: _buildTypeButton(
context: context,
label: '买入',
isActive: isBuy,
color: AppColorScheme.up,
icon: LucideIcons.trendingUp,
onTap: () => onTradeTypeChanged(0),
),
),
SizedBox(width: AppSpacing.sm),
SizedBox(width: 4),
Expanded(
child: _typeButton('卖出', !isBuy, AppColorScheme.down, () => onTradeTypeChanged(1)),
child: _buildTypeButton(
context: context,
label: '卖出',
isActive: !isBuy,
color: AppColorScheme.down,
icon: LucideIcons.trendingDown,
onTap: () => onTradeTypeChanged(1),
),
),
],
),
@@ -743,157 +806,188 @@ class _TradeFormCard extends StatelessWidget {
SizedBox(height: AppSpacing.lg),
// 交易金额输入
Text('交易金额 (USDT)',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w700,
letterSpacing: 0.2,
color: colorScheme.onSurfaceVariant,
)),
SizedBox(height: AppSpacing.xs),
Container(
decoration: BoxDecoration(
color: colorScheme.surfaceContainerLowest,
borderRadius: BorderRadius.circular(AppRadius.xl),
border: Border.all(color: colorScheme.outlineVariant.withOpacity(0.3)),
),
child: TextField(
controller: amountController,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
onChanged: (_) => onAmountChanged(),
style: GoogleFonts.spaceGrotesk(
fontSize: 20,
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
decoration: InputDecoration(
hintText: '输入金额',
hintStyle: TextStyle(
color: colorScheme.outlineVariant.withOpacity(0.5)),
border: InputBorder.none,
contentPadding: EdgeInsets.symmetric(
horizontal: AppSpacing.md,
vertical: AppSpacing.md,
),
suffixIcon: Padding(
padding: EdgeInsets.only(right: AppSpacing.sm),
child: Text('USDT',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: colorScheme.onSurfaceVariant,
)),
),
suffixIconConstraints: const BoxConstraints(minWidth: 50),
),
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('交易金额',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: colorScheme.onSurface,
)),
Text('USDT',
style: GoogleFonts.spaceGrotesk(
fontSize: 11,
fontWeight: FontWeight.w700,
color: actionColor.withOpacity(0.7),
letterSpacing: 0.5,
)),
],
),
SizedBox(height: AppSpacing.sm),
_AmountInput(
amountController: amountController,
maxAmount: maxAmount,
isBuy: isBuy,
actionColor: actionColor,
onChanged: onAmountChanged,
),
SizedBox(height: AppSpacing.sm),
// 快捷比例按钮
// 快捷比例按钮 - 药丸样式
Row(
children: [
_pctButton('25%', 0.25, colorScheme),
SizedBox(width: AppSpacing.xs),
_pctButton('50%', 0.5, colorScheme),
SizedBox(width: AppSpacing.xs),
_pctButton('75%', 0.75, colorScheme),
SizedBox(width: AppSpacing.xs),
_pctButton('全部', 1.0, colorScheme),
_buildPctButton('25%', 0.25, colorScheme, actionColor),
SizedBox(width: 6),
_buildPctButton('50%', 0.5, colorScheme, actionColor),
SizedBox(width: 6),
_buildPctButton('75%', 0.75, colorScheme, actionColor),
SizedBox(width: 6),
_buildPctButton('全部', 1.0, colorScheme, actionColor),
],
),
SizedBox(height: AppSpacing.lg),
// 预计数量
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('预计数量',
style: TextStyle(
fontSize: 13,
color: colorScheme.onSurfaceVariant,
)),
Text(
'$calculatedQuantity ${selectedCoin?.code ?? ''}',
style: GoogleFonts.spaceGrotesk(
fontSize: 14,
fontWeight: FontWeight.w600,
color: colorScheme.onSurface,
// 预计数量 + 可用余额 - 卡片样式
Container(
padding: EdgeInsets.all(AppSpacing.md),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerLowest,
borderRadius: BorderRadius.circular(AppRadius.lg),
border: Border.all(
color: colorScheme.outlineVariant.withOpacity(0.1)),
),
child: Column(
children: [
// 预计数量
Row(
children: [
Icon(LucideIcons.calculator,
size: 14, color: colorScheme.onSurfaceVariant),
SizedBox(width: AppSpacing.sm),
Text('预计数量',
style: TextStyle(
fontSize: 12,
color: colorScheme.onSurfaceVariant,
)),
const Spacer(),
Text(
'$calculatedQuantity ${selectedCoin?.code ?? ''}',
style: GoogleFonts.spaceGrotesk(
fontSize: 14,
fontWeight: FontWeight.w600,
color: colorScheme.onSurface,
),
),
],
),
),
],
),
SizedBox(height: AppSpacing.md),
// 可用余额
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(isBuy ? '可用 USDT' : '可用 ${selectedCoin?.code ?? ""}',
style: TextStyle(
fontSize: 13,
color: colorScheme.onSurfaceVariant,
)),
Text(
isBuy
? '$availableUsdt USDT'
: '$availableCoinQty ${selectedCoin?.code ?? ""}',
style: GoogleFonts.spaceGrotesk(
fontSize: 14,
fontWeight: FontWeight.w600,
color: colorScheme.onSurface,
Padding(
padding: EdgeInsets.symmetric(vertical: AppSpacing.sm),
child: Divider(
height: 1,
color: colorScheme.outlineVariant.withOpacity(0.08)),
),
),
],
// 可用余额
Row(
children: [
Icon(LucideIcons.wallet,
size: 14, color: colorScheme.onSurfaceVariant),
SizedBox(width: AppSpacing.sm),
Text(isBuy ? '可用 USDT' : '可用 ${selectedCoin?.code ?? ""}',
style: TextStyle(
fontSize: 12,
color: colorScheme.onSurfaceVariant,
)),
const Spacer(),
Text(
isBuy
? '$availableUsdt USDT'
: '$availableCoinQty ${selectedCoin?.code ?? ""}',
style: GoogleFonts.spaceGrotesk(
fontSize: 14,
fontWeight: FontWeight.w600,
color: colorScheme.onSurface,
),
),
],
),
],
),
),
],
),
);
}
Widget _typeButton(
String label, bool isActive, Color color, VoidCallback onTap) {
/// 买入/卖出切换按钮 - 实心填充 + 图标 + 光效
Widget _buildTypeButton({
required BuildContext context,
required String label,
required bool isActive,
required Color color,
required IconData icon,
required VoidCallback onTap,
}) {
return GestureDetector(
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: EdgeInsets.symmetric(vertical: AppSpacing.sm + AppSpacing.xs),
duration: const Duration(milliseconds: 250),
curve: Curves.easeInOut,
padding: EdgeInsets.symmetric(vertical: AppSpacing.sm + 4),
decoration: BoxDecoration(
color: isActive ? color.withOpacity(0.15) : Colors.transparent,
borderRadius: BorderRadius.circular(AppRadius.md),
border: isActive ? null : Border.all(color: color.withOpacity(0.3)),
color: isActive ? color : Colors.transparent,
borderRadius: BorderRadius.circular(AppRadius.lg),
boxShadow: isActive
? [
BoxShadow(
color: color.withOpacity(0.35),
blurRadius: 16,
offset: const Offset(0, 4),
),
]
: null,
),
child: Center(
child: Text(
label,
style: TextStyle(
color: isActive ? color : color.withOpacity(0.7),
fontWeight: FontWeight.w700,
fontSize: 14,
letterSpacing: 0.5,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon,
size: 16,
color: isActive ? Colors.white : color.withOpacity(0.5)),
SizedBox(width: AppSpacing.xs),
Text(
label,
style: TextStyle(
color: isActive ? Colors.white : color.withOpacity(0.5),
fontWeight: FontWeight.w700,
fontSize: 15,
letterSpacing: 0.5,
),
),
),
],
),
),
);
}
Widget _pctButton(String label, double pct, ColorScheme colorScheme) {
/// 百分比按钮 - 药丸样式
Widget _buildPctButton(
String label, double pct, ColorScheme colorScheme, Color actionColor) {
return Expanded(
child: GestureDetector(
onTap: () => onFillPercent(pct),
child: Container(
padding: EdgeInsets.symmetric(vertical: AppSpacing.xs + 2),
padding: EdgeInsets.symmetric(vertical: AppSpacing.sm - 2),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(AppRadius.sm),
color: actionColor.withOpacity(0.06),
borderRadius: BorderRadius.circular(AppRadius.full),
border: Border.all(color: actionColor.withOpacity(0.12)),
),
child: Center(
child: Text(label,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: colorScheme.onSurfaceVariant,
color: actionColor.withOpacity(0.8),
)),
),
),
@@ -902,7 +996,7 @@ class _TradeFormCard extends StatelessWidget {
}
}
/// 交易按钮
/// 交易按钮 - 使用 NeonButton 风格
class _TradeButton extends StatelessWidget {
final bool isBuy;
final String? coinCode;
@@ -921,42 +1015,199 @@ class _TradeButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final isDark = Theme.of(context).brightness == Brightness.dark;
final actionColor = isBuy ? AppColorScheme.up : AppColorScheme.down;
final gradient = isBuy
? AppColorScheme.getBuyGradient(isDark)
: AppColorScheme.sellGradient;
return SizedBox(
width: double.infinity,
height: 52,
child: ElevatedButton(
onPressed: enabled ? onPressed : null,
style: ElevatedButton.styleFrom(
backgroundColor:
isBuy ? AppColorScheme.up : AppColorScheme.down,
disabledBackgroundColor: colorScheme.onSurface.withOpacity(0.12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.xl),
),
elevation: isBuy && enabled ? 4 : 0,
shadowColor:
isBuy ? AppColorScheme.up.withOpacity(0.3) : Colors.transparent,
return GestureDetector(
onTap: enabled ? onPressed : null,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
height: 52,
decoration: BoxDecoration(
gradient: enabled ? gradient : null,
color: enabled ? null : colorScheme.onSurface.withOpacity(0.08),
borderRadius: BorderRadius.circular(AppRadius.xxl),
boxShadow: enabled
? [
BoxShadow(
color: actionColor.withOpacity(isDark ? 0.25 : 0.15),
blurRadius: 20,
offset: const Offset(0, 4),
),
]
: null,
),
child: isLoading
? SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: isBuy ? Colors.black87 : Colors.white,
child: Center(
child: isLoading
? SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: isBuy
? AppColorScheme.darkOnTertiary
: Colors.white,
),
)
: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
isBuy ? LucideIcons.trendingUp : LucideIcons.trendingDown,
size: 16,
color: enabled
? (isBuy
? AppColorScheme.darkOnTertiary
: Colors.white)
: colorScheme.onSurface.withOpacity(0.3),
),
SizedBox(width: AppSpacing.xs),
Text(
'${isBuy ? '买入' : '卖出'} ${coinCode ?? ""}',
style: GoogleFonts.spaceGrotesk(
fontSize: 16,
fontWeight: FontWeight.w700,
color: enabled
? (isBuy
? AppColorScheme.darkOnTertiary
: Colors.white)
: colorScheme.onSurface.withOpacity(0.3),
letterSpacing: 0.5,
),
),
],
),
)
: Text(
'${isBuy ? '买入' : '卖出'} ${coinCode ?? ""}',
style: GoogleFonts.spaceGrotesk(
fontSize: 16,
fontWeight: FontWeight.w700,
color: isBuy ? Colors.black87 : Colors.white,
letterSpacing: 1,
),
),
),
),
);
}
}
/// 金额输入框(含超额提示)
class _AmountInput extends StatefulWidget {
final TextEditingController amountController;
final String maxAmount;
final bool isBuy;
final Color actionColor;
final VoidCallback onChanged;
const _AmountInput({
required this.amountController,
required this.maxAmount,
required this.isBuy,
required this.actionColor,
required this.onChanged,
});
@override
State<_AmountInput> createState() => _AmountInputState();
}
class _AmountInputState extends State<_AmountInput> {
bool _isExceeded = false;
void _checkLimit() {
final input = double.tryParse(widget.amountController.text) ?? 0;
final max = double.tryParse(widget.maxAmount) ?? 0;
final exceeded = widget.isBuy && input > max && max > 0 && input > 0;
if (exceeded != _isExceeded) {
setState(() => _isExceeded = exceeded);
}
widget.onChanged();
}
@override
void initState() {
super.initState();
widget.amountController.addListener(_checkLimit);
}
@override
void dispose() {
widget.amountController.removeListener(_checkLimit);
super.dispose();
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final warningColor = AppColorScheme.warning;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
decoration: BoxDecoration(
color: colorScheme.surfaceContainerLowest,
borderRadius: BorderRadius.circular(AppRadius.xl),
border: Border.all(
color: _isExceeded
? warningColor.withOpacity(0.5)
: widget.actionColor.withOpacity(0.15),
),
),
child: TextField(
controller: widget.amountController,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
onChanged: (_) => _checkLimit(),
style: GoogleFonts.spaceGrotesk(
fontSize: 22,
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
decoration: InputDecoration(
hintText: '0.00',
hintStyle: TextStyle(
color: colorScheme.outlineVariant.withOpacity(0.4)),
border: InputBorder.none,
contentPadding: EdgeInsets.symmetric(
horizontal: AppSpacing.md,
vertical: AppSpacing.md,
),
suffixIcon: Padding(
padding: EdgeInsets.only(right: AppSpacing.sm),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding: EdgeInsets.symmetric(
horizontal: AppSpacing.sm, vertical: AppSpacing.xs),
decoration: BoxDecoration(
color: widget.actionColor.withOpacity(0.08),
borderRadius: BorderRadius.circular(AppRadius.sm),
),
child: Text('USDT',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
color: widget.actionColor.withOpacity(0.7),
)),
),
],
),
),
suffixIconConstraints: const BoxConstraints(minWidth: 60),
),
),
),
if (_isExceeded)
Padding(
padding: EdgeInsets.only(top: AppSpacing.xs),
child: Row(
children: [
Icon(Icons.error_outline, size: 13, color: warningColor),
SizedBox(width: 4),
Text(
'超出可用USDT余额',
style: TextStyle(fontSize: 11, color: warningColor),
),
],
),
),
],
);
}
}