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

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