This commit is contained in:
sion
2026-04-06 16:34:02 +08:00
parent 71c8689989
commit 2e34072f45
20 changed files with 2278 additions and 0 deletions

View File

@@ -0,0 +1,88 @@
/// K线蜡烛数据模型
class KlineCandle {
final String coinCode;
final String interval;
final int openTime;
final double openPrice;
final double highPrice;
final double lowPrice;
final double closePrice;
final double volume;
final int closeTime;
final bool isClosed;
final int? timestamp;
const KlineCandle({
required this.coinCode,
required this.interval,
required this.openTime,
required this.openPrice,
required this.highPrice,
required this.lowPrice,
required this.closePrice,
required this.volume,
required this.closeTime,
this.isClosed = true,
this.timestamp,
});
factory KlineCandle.fromJson(Map<String, dynamic> json) {
return KlineCandle(
coinCode: json['coinCode'] as String? ?? '',
interval: json['interval'] as String? ?? '1h',
openTime: json['openTime'] as int? ?? 0,
openPrice: _toDouble(json['openPrice']),
highPrice: _toDouble(json['highPrice']),
lowPrice: _toDouble(json['lowPrice']),
closePrice: _toDouble(json['closePrice']),
volume: _toDouble(json['volume']),
closeTime: json['closeTime'] as int? ?? 0,
isClosed: json['isClosed'] as bool? ?? true,
timestamp: json['timestamp'] as int?,
);
}
static double _toDouble(dynamic v) {
if (v == null) return 0.0;
if (v is double) return v;
if (v is int) return v.toDouble();
if (v is String) return double.tryParse(v) ?? 0.0;
return 0.0;
}
/// 转换为 k_chart 库的 KLineEntity 格式
Map<String, dynamic> toKLineEntityMap() {
return {
'open': openPrice,
'high': highPrice,
'low': lowPrice,
'close': closePrice,
'vol': volume,
'amount': closePrice * volume,
'time': openTime,
'id': openTime,
};
}
/// 转换为 k_chart KLineEntity 对象
dynamic toKLineEntity() {
// k_chart KLineEntity.fromCustom 构造器
return null; // placeholder, actual conversion in kline_page
}
/// 从 REST API JSON 转换历史K线
factory KlineCandle.fromHistoryJson(Map<String, dynamic> json) {
return KlineCandle(
coinCode: json['coinCode'] as String? ?? '',
interval: json['interval'] as String? ?? '1h',
openTime: json['openTime'] as int? ?? 0,
openPrice: _toDouble(json['openPrice']),
highPrice: _toDouble(json['highPrice']),
lowPrice: _toDouble(json['lowPrice']),
closePrice: _toDouble(json['closePrice']),
volume: _toDouble(json['volume']),
closeTime: json['closeTime'] as int? ?? 0,
isClosed: true,
);
}
}

View File

@@ -0,0 +1,72 @@
import '../../core/constants/api_endpoints.dart';
import '../../core/network/api_response.dart';
import '../../core/network/dio_client.dart';
import '../models/kline_candle.dart';
/// K线 REST API 服务
class KlineService {
final DioClient _client;
KlineService(this._client);
/// 获取历史K线数据
Future<ApiResponse<List<KlineCandle>>> fetchHistory({
required String coinCode,
required String interval,
int limit = 200,
int? before,
}) async {
final params = <String, dynamic>{
'coinCode': coinCode,
'interval': interval,
'limit': limit,
};
if (before != null) params['before'] = before;
final response = await _client.get<Map<String, dynamic>>(
ApiEndpoints.klineHistory,
queryParameters: params,
);
if (response.success && response.data != null) {
final list = response.data!['list'] as List? ?? [];
final candles = list
.map((e) => KlineCandle.fromHistoryJson(e as Map<String, dynamic>))
.toList();
return ApiResponse.success(candles, response.message);
}
return ApiResponse.fail(response.message ?? '获取K线数据失败');
}
/// 获取当前进行中的K线
Future<ApiResponse<KlineCandle>> fetchCurrentCandle({
required String coinCode,
required String interval,
}) async {
final response = await _client.get<Map<String, dynamic>>(
ApiEndpoints.klineCurrent,
queryParameters: {'coinCode': coinCode, 'interval': interval},
);
if (response.success && response.data != null) {
return ApiResponse.success(
KlineCandle.fromJson(response.data!),
response.message,
);
}
return ApiResponse.fail(response.message ?? '获取当前K线失败');
}
/// 获取支持的周期列表
Future<ApiResponse<List<String>>> fetchIntervals() async {
final response = await _client.get<Map<String, dynamic>>(
ApiEndpoints.klineIntervals,
);
if (response.success && response.data != null) {
final list = response.data!['list'] as List? ?? [];
return ApiResponse.success(list.cast<String>(), response.message);
}
return ApiResponse.fail(response.message ?? '获取周期列表失败');
}
}

View File

@@ -0,0 +1,135 @@
import 'dart:async';
import 'dart:convert';
import 'package:stomp_dart_client/stomp_dart_client.dart';
import '../../core/constants/api_endpoints.dart';
import '../models/kline_candle.dart';
/// K线 WebSocket 服务STOMP 协议)
class KlineWebSocketService {
StompClient? _stompClient;
final Map<String, dynamic> _subscriptions = {};
final Map<String, StreamController<KlineCandle>> _controllers = {};
bool _isConnected = false;
bool _isConnecting = false;
int _reconnectDelay = 2000; // 初始重连延迟
static const int _maxReconnectDelay = 30000;
/// 订阅某个币种的K线数据
Stream<KlineCandle> subscribe(String coinCode) {
final key = coinCode.toUpperCase();
if (!_controllers.containsKey(key)) {
_controllers[key] = StreamController<KlineCandle>.broadcast();
}
_doSubscribe(key);
return _controllers[key]!.stream;
}
/// 取消订阅
void unsubscribe(String coinCode) {
final key = coinCode.toUpperCase();
_subscriptions[key]?.call();
_subscriptions.remove(key);
}
/// 连接状态
bool get isConnected => _isConnected;
/// 连接 WebSocket
void connect() {
if (_isConnecting || _isConnected) return;
_isConnecting = true;
final wsUrl = ApiEndpoints.klineWs;
_stompClient = StompClient(
config: StompConfig(
url: wsUrl,
onConnect: _onConnect,
onDisconnect: _onDisconnect,
onStompError: _onError,
onWebSocketError: _onError,
reconnectDelay: const Duration(milliseconds: 5000),
heartbeatIncoming: const Duration(seconds: 20),
heartbeatOutgoing: const Duration(seconds: 20),
),
);
_stompClient!.activate();
}
/// 断开连接
void disconnect() {
_isConnecting = false;
_stompClient?.deactivate();
_stompClient = null;
_isConnected = false;
_subscriptions.clear();
}
void _onConnect(StompFrame frame) {
_isConnected = true;
_isConnecting = false;
_reconnectDelay = 2000; // 重置重连延迟
// 重新订阅所有已注册的币种
for (final key in _controllers.keys) {
_doSubscribe(key);
}
}
void _onDisconnect(StompFrame? frame) {
_isConnected = false;
_isConnecting = false;
_scheduleReconnect();
}
void _onError(dynamic error) {
_isConnected = false;
_isConnecting = false;
_scheduleReconnect();
}
void _scheduleReconnect() {
Future.delayed(Duration(milliseconds: _reconnectDelay), () {
if (!_isConnected && !_isConnecting) {
_reconnectDelay = (_reconnectDelay * 2).clamp(2000, _maxReconnectDelay);
connect();
}
});
}
void _doSubscribe(String coinCode) {
if (_stompClient == null || !_isConnected) {
connect(); // 触发连接,连接成功后会自动重新订阅
return;
}
// 避免重复订阅
if (_subscriptions.containsKey(coinCode)) return;
final dest = '/topic/kline/$coinCode';
final sub = _stompClient!.subscribe(
destination: dest,
callback: (StompFrame frame) {
if (frame.body != null) {
try {
final json = jsonDecode(frame.body!) as Map<String, dynamic>;
final candle = KlineCandle.fromJson(json);
_controllers[coinCode]?.add(candle);
} catch (_) {}
}
},
);
_subscriptions[coinCode] = sub;
}
/// 释放资源
void dispose() {
disconnect();
for (final controller in _controllers.values) {
controller.close();
}
_controllers.clear();
}
}

View File

@@ -0,0 +1,183 @@
import 'dart:async';
import 'package:flutter/material.dart';
import '../data/models/kline_candle.dart';
import '../data/services/kline_service.dart';
import '../data/services/kline_websocket_service.dart';
/// K线状态管理
class KlineProvider extends ChangeNotifier {
final KlineService _klineService;
final KlineWebSocketService _wsService;
KlineProvider(this._klineService, this._wsService);
List<KlineCandle> _candles = [];
KlineCandle? _currentCandle;
String _interval = '1h';
String _coinCode = '';
bool _isLoading = false;
bool _isLoadingMore = false;
bool _isConnected = false;
String? _error;
StreamSubscription<KlineCandle>? _wsSubscription;
Timer? _pollingTimer;
// Getters
List<KlineCandle> get candles => _candles;
KlineCandle? get currentCandle => _currentCandle;
String get interval => _interval;
String get coinCode => _coinCode;
bool get isLoading => _isLoading;
bool get isLoadingMore => _isLoadingMore;
bool get isConnected => _isConnected;
String? get error => _error;
/// 加载某个币种的K线数据
Future<void> loadCoin(String coinCode, {String? interval}) async {
_coinCode = coinCode;
if (interval != null) _interval = interval;
_candles = [];
_currentCandle = null;
_error = null;
_isLoading = true;
notifyListeners();
try {
// 1. 获取历史K线
final response = await _klineService.fetchHistory(
coinCode: _coinCode,
interval: _interval,
limit: 200,
);
if (response.success && response.data != null) {
_candles = response.data!;
}
// 2. 获取当前K线
final currentResponse = await _klineService.fetchCurrentCandle(
coinCode: _coinCode,
interval: _interval,
);
if (currentResponse.success && currentResponse.data != null) {
_currentCandle = currentResponse.data;
}
// 3. 连接 WebSocket
_connectWebSocket();
_isLoading = false;
notifyListeners();
} catch (e) {
_error = '加载K线数据失败: $e';
_isLoading = false;
notifyListeners();
}
}
/// 切换周期
Future<void> changeInterval(String newInterval) async {
if (newInterval == _interval) return;
_interval = newInterval;
_candles = [];
_currentCandle = null;
// 重新加载
await loadCoin(_coinCode, interval: newInterval);
}
/// 加载更多历史数据(分页)
Future<void> loadMore() async {
if (_isLoadingMore || _candles.isEmpty) return;
_isLoadingMore = true;
notifyListeners();
try {
final oldestTime = _candles.first.closeTime;
final response = await _klineService.fetchHistory(
coinCode: _coinCode,
interval: _interval,
limit: 200,
before: oldestTime,
);
if (response.success && response.data != null) {
_candles = [...response.data!, ..._candles];
}
} catch (_) {}
_isLoadingMore = false;
notifyListeners();
}
void _connectWebSocket() {
// 取消之前的订阅
_wsSubscription?.cancel();
_wsService.unsubscribe(_coinCode);
// 订阅新币种
_wsService.connect();
_wsSubscription = _wsService.subscribe(_coinCode).listen(
_onTick,
onError: (_) => _startPolling(),
onDone: () => _startPolling(),
);
_isConnected = _wsService.isConnected;
notifyListeners();
}
void _onTick(KlineCandle tick) {
if (tick.interval != _interval) return;
_isConnected = true;
if (tick.isClosed) {
// 收盘 tick → 添加到历史列表
_candles.add(tick);
_currentCandle = null;
// 停止轮询(如果之前在轮询)
_pollingTimer?.cancel();
_pollingTimer = null;
} else {
// 进行中的 tick → 更新当前K线
_currentCandle = tick;
}
notifyListeners();
}
/// WebSocket 断连时降级为 HTTP 轮询
void _startPolling() {
_isConnected = false;
notifyListeners();
_pollingTimer?.cancel();
_pollingTimer = Timer.periodic(
const Duration(seconds: 5),
(_) => _pollCurrentCandle(),
);
}
Future<void> _pollCurrentCandle() async {
try {
final response = await _klineService.fetchCurrentCandle(
coinCode: _coinCode,
interval: _interval,
);
if (response.success && response.data != null) {
_currentCandle = response.data;
notifyListeners();
}
} catch (_) {}
}
@override
void dispose() {
_wsSubscription?.cancel();
_wsService.unsubscribe(_coinCode);
_pollingTimer?.cancel();
super.dispose();
}
}

View File

@@ -0,0 +1,56 @@
import 'package:flutter/material.dart';
import '../../../../core/theme/app_theme.dart';
import '../../../../core/theme/app_theme_extension.dart';
/// K线周期选择器15m / 1h / 4h / 1d / 1M
class IntervalSelector extends StatelessWidget {
final String selected;
final ValueChanged<String> onChanged;
static const List<MapEntry<String, String>> intervals = [
MapEntry('15m', '15分'),
MapEntry('1h', '1时'),
MapEntry('4h', '4时'),
MapEntry('1d', '日线'),
MapEntry('1M', '月线'),
];
const IntervalSelector({
super.key,
required this.selected,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
return Row(
children: intervals.map((e) {
final isSelected = e.key == selected;
return Expanded(
child: GestureDetector(
onTap: () => onChanged(e.key),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 8),
decoration: BoxDecoration(
color: isSelected
? context.colors.primary.withValues(alpha: 0.15)
: Colors.transparent,
borderRadius: BorderRadius.circular(6),
),
alignment: Alignment.center,
child: Text(
e.value,
style: AppTextStyles.bodyMedium(context).copyWith(
color: isSelected
? context.colors.primary
: context.appColors.onSurfaceMuted,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400,
),
),
),
),
);
}).toList(),
);
}
}

View File

@@ -0,0 +1,77 @@
import 'package:flutter/material.dart';
import '../../../../core/theme/app_theme.dart';
import '../../../../core/theme/app_theme_extension.dart';
import '../../../../data/models/kline_candle.dart';
/// K线 OHLC 信息栏
class KlineStatsBar extends StatelessWidget {
final KlineCandle? candle;
const KlineStatsBar({super.key, this.candle});
@override
Widget build(BuildContext context) {
if (candle == null) return const SizedBox.shrink();
final c = candle!;
final change = c.closePrice - c.openPrice;
final changePct = c.openPrice > 0 ? (change / c.openPrice * 100) : 0.0;
final isUp = change >= 0;
final color = isUp ? context.appColors.up : context.appColors.down;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
child: Row(
children: [
_statItem(context, '', _fmt(c.openPrice), color),
const SizedBox(width: 12),
_statItem(context, '', _fmt(c.highPrice), color),
const SizedBox(width: 12),
_statItem(context, '', _fmt(c.lowPrice), color),
const SizedBox(width: 12),
_statItem(context, '', _fmt(c.closePrice), color),
const SizedBox(width: 12),
_statItem(context, '', _fmtVol(c.volume), color),
const Spacer(),
Text(
'${isUp ? '+' : ''}${changePct.toStringAsFixed(2)}%',
style: AppTextStyles.labelLarge(context).copyWith(
color: color,
fontWeight: FontWeight.bold,
),
),
],
),
);
}
Widget _statItem(BuildContext context, String label, String value, Color color) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(label,
style: AppTextStyles.bodySmall(context).copyWith(
color: context.appColors.onSurfaceMuted,
)),
const SizedBox(width: 2),
Text(value,
style: AppTextStyles.bodySmall(context).copyWith(
color: color,
fontWeight: FontWeight.w500,
)),
],
);
}
String _fmt(double v) {
if (v >= 1000) return v.toStringAsFixed(2);
if (v >= 1) return v.toStringAsFixed(4);
return v.toStringAsFixed(6);
}
String _fmtVol(double v) {
if (v >= 1000000) return '${(v / 1000000).toStringAsFixed(1)}M';
if (v >= 1000) return '${(v / 1000).toStringAsFixed(1)}K';
return v.toStringAsFixed(0);
}
}

View File

@@ -0,0 +1,280 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import 'package:k_chart/flutter_k_chart.dart';
import '../../../core/theme/app_theme.dart';
import '../../../core/theme/app_theme_extension.dart';
import '../../../core/theme/app_spacing.dart';
import '../../../data/models/coin.dart';
import '../../../providers/kline_provider.dart';
import '../main/main_page.dart';
import 'components/interval_selector.dart';
import 'components/kline_stats_bar.dart';
/// K线图表页面
class KlinePage extends StatefulWidget {
final Coin coin;
const KlinePage({super.key, required this.coin});
@override
State<KlinePage> createState() => _KlinePageState();
}
class _KlinePageState extends State<KlinePage> {
List<KLineEntity>? _kLineEntities;
final ChartColors _chartColors = ChartColors();
final ChartStyle _chartStyle = ChartStyle();
@override
void initState() {
super.initState();
}
@override
Widget build(BuildContext context) {
final isDark = context.isDark;
_chartColors.bgColor = [isDark ? const Color(0xff1a1a2e) : Colors.white, isDark ? const Color(0xff1a1a2e) : Colors.white];
_chartColors.gridColor = isDark ? const Color(0xff2d2d44) : const Color(0xffe0e0e0);
_chartColors.upColor = context.appColors.up;
_chartColors.dnColor = context.appColors.down;
return Scaffold(
backgroundColor: context.colors.background,
appBar: AppBar(
backgroundColor: context.colors.surface,
elevation: 0,
scrolledUnderElevation: 0,
leading: IconButton(
icon: Icon(LucideIcons.arrowLeft, color: context.colors.onSurface),
onPressed: () => Navigator.of(context).pop(),
),
title: Row(
children: [
Text(widget.coin.code,
style: AppTextStyles.headlineLarge(context).copyWith(
fontWeight: FontWeight.bold,
)),
const SizedBox(width: 8),
Text(widget.coin.formattedPrice,
style: AppTextStyles.headlineMedium(context).copyWith(
color: context.colors.primary,
)),
const SizedBox(width: 6),
_ChangeBadge(coin: widget.coin),
],
),
),
body: Consumer<KlineProvider>(
builder: (context, provider, _) {
// 数据转换
_updateEntities(provider);
return Column(
children: [
// 周期选择器
Padding(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.md,
vertical: AppSpacing.sm,
),
child: IntervalSelector(
selected: provider.interval,
onChanged: (v) => provider.changeInterval(v),
),
),
// OHLC 信息栏
KlineStatsBar(candle: provider.currentCandle),
const Divider(height: 1),
// K线图表
Expanded(
child: _buildChart(provider),
),
// 底部操作栏
_BottomActionBar(coin: widget.coin),
],
);
},
),
);
}
void _updateEntities(KlineProvider provider) {
final allCandles = [...provider.candles];
if (provider.currentCandle != null) {
allCandles.add(provider.currentCandle!);
}
if (allCandles.isEmpty) {
_kLineEntities = null;
return;
}
_kLineEntities = allCandles.map((c) {
return KLineEntity.fromJson({
'open': c.openPrice,
'high': c.highPrice,
'low': c.lowPrice,
'close': c.closePrice,
'vol': c.volume,
'amount': c.closePrice * c.volume,
'time': c.openTime,
'id': c.openTime,
});
}).toList();
DataUtil.calculate(_kLineEntities!);
}
Widget _buildChart(KlineProvider provider) {
if (provider.isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (_kLineEntities == null || _kLineEntities!.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(LucideIcons.chartNoAxesColumn,
size: 48,
color: context.appColors.onSurfaceMuted.withValues(alpha: 0.4)),
const SizedBox(height: AppSpacing.md),
Text('暂无K线数据',
style: AppTextStyles.headlineMedium(context).copyWith(
color: context.appColors.onSurfaceMuted,
)),
const SizedBox(height: AppSpacing.sm),
],
),
);
}
return Stack(
children: [
SizedBox(
height: double.infinity,
width: double.infinity,
child: KChartWidget(
_kLineEntities,
_chartStyle,
_chartColors,
isLine: false,
isTrendLine: false,
mainState: MainState.MA,
volHidden: false,
secondaryState: SecondaryState.MACD,
fixedLength: 4,
timeFormat: TimeFormat.YEAR_MONTH_DAY,
showNowPrice: true,
hideGrid: false,
isTapShowInfoDialog: false,
onSecondaryTap: () {},
),
),
],
);
}
}
/// 涨跌标签
class _ChangeBadge extends StatelessWidget {
final Coin coin;
const _ChangeBadge({required this.coin});
@override
Widget build(BuildContext context) {
final isUp = coin.isUp;
final color = isUp ? context.appColors.up : context.appColors.down;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(4),
),
child: Text(
coin.formattedChange,
style: AppTextStyles.bodySmall(context).copyWith(
color: color,
fontWeight: FontWeight.w600,
),
),
);
}
}
/// 底部交易操作栏
class _BottomActionBar extends StatelessWidget {
final Coin coin;
const _BottomActionBar({required this.coin});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.fromLTRB(
AppSpacing.lg, AppSpacing.sm, AppSpacing.lg, AppSpacing.lg,
),
decoration: BoxDecoration(
color: context.colors.surface,
border: Border(
top: BorderSide(
color: context.colors.outlineVariant.withValues(alpha: 0.2),
),
),
),
child: Row(
children: [
Expanded(
child: SizedBox(
height: 44,
child: ElevatedButton(
onPressed: () => _navigateToTrade(context, isBuy: true),
style: ElevatedButton.styleFrom(
backgroundColor: context.appColors.up,
foregroundColor: Colors.white,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.lg),
),
),
child: Text('买入',
style: AppTextStyles.headlineMedium(context).copyWith(
color: Colors.white,
fontWeight: FontWeight.bold,
)),
),
),
),
const SizedBox(width: AppSpacing.md),
Expanded(
child: SizedBox(
height: 44,
child: ElevatedButton(
onPressed: () => _navigateToTrade(context, isBuy: false),
style: ElevatedButton.styleFrom(
backgroundColor: context.appColors.down,
foregroundColor: Colors.white,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.lg),
),
),
child: Text('卖出',
style: AppTextStyles.headlineMedium(context).copyWith(
color: Colors.white,
fontWeight: FontWeight.bold,
)),
),
),
),
],
),
);
}
void _navigateToTrade(BuildContext context, {required bool isBuy}) {
Navigator.of(context).pop();
final mainState = context.findAncestorStateOfType<MainPageState>();
mainState?.switchToTrade(coin.code);
}
}