111
This commit is contained in:
88
flutter_monisuo/lib/data/models/kline_candle.dart
Normal file
88
flutter_monisuo/lib/data/models/kline_candle.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
72
flutter_monisuo/lib/data/services/kline_service.dart
Normal file
72
flutter_monisuo/lib/data/services/kline_service.dart
Normal 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 ?? '获取周期列表失败');
|
||||
}
|
||||
}
|
||||
135
flutter_monisuo/lib/data/services/kline_websocket_service.dart
Normal file
135
flutter_monisuo/lib/data/services/kline_websocket_service.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
183
flutter_monisuo/lib/providers/kline_provider.dart
Normal file
183
flutter_monisuo/lib/providers/kline_provider.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
280
flutter_monisuo/lib/ui/pages/kline/kline_page.dart
Normal file
280
flutter_monisuo/lib/ui/pages/kline/kline_page.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user