This commit is contained in:
sion
2026-04-21 08:09:45 +08:00
parent 0066615054
commit 5264043c21
1831 changed files with 15376 additions and 39973 deletions

View File

@@ -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);
},
);
}),
],
),
),
),
),
],
);
},
),

View File

@@ -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(

View File

@@ -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);

View File

@@ -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();
}

View File

@@ -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,

View File

@@ -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,
),
),
);
}
}

View File

@@ -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,
),
],
),
),
);
}
}

View File

@@ -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,
),
),
],
),
],
),
),
);
}

View File

@@ -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(() {

View File

@@ -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('重試')),
],
],
),

View File

@@ -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(

View File

@@ -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,
),
],
),
);

View File

@@ -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,
),
],
),
),
],

View File

@@ -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)),

View File

@@ -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('獎勵直接發放至資金賬戶'),
],
),

View File

@@ -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)),
],
),
],
),
),

View File

@@ -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);
}
}

View File

@@ -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()),
],
),
);