feat: 添加K线图功能 - interactive_chart + 多币种切换 + 技术指标(MA/EMA/BOLL/VOL)
This commit is contained in:
38
flutter_monisuo/lib/models/candle.dart
Normal file
38
flutter_monisuo/lib/models/candle.dart
Normal file
@@ -0,0 +1,38 @@
|
||||
/// K 线数据模型
|
||||
class Candle {
|
||||
final int timestamp; // 毫秒时间戳
|
||||
final double open;
|
||||
final double high;
|
||||
final double low;
|
||||
final double close;
|
||||
final double volume;
|
||||
|
||||
const Candle({
|
||||
required this.timestamp,
|
||||
required this.open,
|
||||
required this.high,
|
||||
required this.low,
|
||||
required this.close,
|
||||
required this.volume,
|
||||
});
|
||||
|
||||
factory Candle.fromJson(Map<String, dynamic> json) {
|
||||
return Candle(
|
||||
timestamp: json['timestamp'] as int? ?? json['time'] as int? ?? 0,
|
||||
open: (json['open'] as num?)?.toDouble() ?? 0,
|
||||
high: (json['high'] as num?)?.toDouble() ?? 0,
|
||||
low: (json['low'] as num?)?.toDouble() ?? 0,
|
||||
close: (json['close'] as num?)?.toDouble() ?? 0,
|
||||
volume: (json['volume'] as num?)?.toDouble() ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'timestamp': timestamp,
|
||||
'open': open,
|
||||
'high': high,
|
||||
'low': low,
|
||||
'close': close,
|
||||
'volume': volume,
|
||||
};
|
||||
}
|
||||
205
flutter_monisuo/lib/providers/chart_provider.dart
Normal file
205
flutter_monisuo/lib/providers/chart_provider.dart
Normal file
@@ -0,0 +1,205 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:interactive_chart/interactive_chart.dart';
|
||||
import '../models/candle.dart';
|
||||
|
||||
/// 时间周期
|
||||
enum ChartInterval {
|
||||
min1('1分', '1m'),
|
||||
min5('5分', '5m'),
|
||||
min15('15分', '15m'),
|
||||
hour1('1小时', '1h'),
|
||||
hour4('4小时', '4h'),
|
||||
day1('1天', '1d');
|
||||
|
||||
final String label;
|
||||
final String value;
|
||||
const ChartInterval(this.label, this.value);
|
||||
}
|
||||
|
||||
/// 技术指标开关
|
||||
class IndicatorSettings {
|
||||
final bool showMA;
|
||||
final bool showEMA;
|
||||
final bool showBOLL;
|
||||
final bool showVOL;
|
||||
final int maPeriod;
|
||||
final int emaPeriod;
|
||||
final int bollPeriod;
|
||||
|
||||
const IndicatorSettings({
|
||||
this.showMA = true,
|
||||
this.showEMA = false,
|
||||
this.showBOLL = false,
|
||||
this.showVOL = true,
|
||||
this.maPeriod = 7,
|
||||
this.emaPeriod = 14,
|
||||
this.bollPeriod = 20,
|
||||
});
|
||||
|
||||
IndicatorSettings copyWith({
|
||||
bool? showMA,
|
||||
bool? showEMA,
|
||||
bool? showBOLL,
|
||||
bool? showVOL,
|
||||
int? maPeriod,
|
||||
int? emaPeriod,
|
||||
int? bollPeriod,
|
||||
}) {
|
||||
return IndicatorSettings(
|
||||
showMA: showMA ?? this.showMA,
|
||||
showEMA: showEMA ?? this.showEMA,
|
||||
showBOLL: showBOLL ?? this.showBOLL,
|
||||
showVOL: showVOL ?? this.showVOL,
|
||||
maPeriod: maPeriod ?? this.maPeriod,
|
||||
emaPeriod: emaPeriod ?? this.emaPeriod,
|
||||
bollPeriod: bollPeriod ?? this.bollPeriod,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// K 线数据 Provider
|
||||
class ChartProvider extends ChangeNotifier {
|
||||
|
||||
// 当前币种
|
||||
String _symbol = 'BTC';
|
||||
String get symbol => _symbol;
|
||||
|
||||
// 时间周期
|
||||
ChartInterval _interval = ChartInterval.hour1;
|
||||
ChartInterval get interval => _interval;
|
||||
|
||||
// 技术指标
|
||||
IndicatorSettings _indicators = const IndicatorSettings();
|
||||
IndicatorSettings get indicators => _indicators;
|
||||
|
||||
// K 线数据
|
||||
List<Candle> _candles = [];
|
||||
List<Candle> get candles => _candles;
|
||||
|
||||
List<CandleData> get candleData => _candles
|
||||
.map((c) => CandleData(
|
||||
timestamp: c.timestamp,
|
||||
open: c.open,
|
||||
high: c.high,
|
||||
low: c.low,
|
||||
close: c.close,
|
||||
volume: c.volume,
|
||||
))
|
||||
.toList();
|
||||
|
||||
// 状态
|
||||
bool _loading = false;
|
||||
bool get loading => _loading;
|
||||
|
||||
String? _error;
|
||||
String? get error => _error;
|
||||
|
||||
/// 切换币种
|
||||
void setSymbol(String symbol) {
|
||||
if (_symbol != symbol) {
|
||||
_symbol = symbol;
|
||||
loadCandles();
|
||||
}
|
||||
}
|
||||
|
||||
/// 切换时间周期
|
||||
void setInterval(ChartInterval interval) {
|
||||
if (_interval != interval) {
|
||||
_interval = interval;
|
||||
loadCandles();
|
||||
}
|
||||
}
|
||||
|
||||
/// 更新指标设置
|
||||
void updateIndicators(IndicatorSettings settings) {
|
||||
_indicators = settings;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// 加载 K 线数据
|
||||
Future<void> loadCandles() async {
|
||||
_loading = true;
|
||||
_error = null;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
// TODO: 对接真实 API
|
||||
// final response = await _api.getKlineData(
|
||||
// symbol: _symbol,
|
||||
// interval: _interval.value,
|
||||
// );
|
||||
|
||||
// 临时使用模拟数据
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
_candles = _generateMockCandles();
|
||||
|
||||
_loading = false;
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
_loading = false;
|
||||
_error = e.toString();
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// 生成模拟 K 线数据
|
||||
List<Candle> _generateMockCandles() {
|
||||
final now = DateTime.now();
|
||||
final basePrice = _getBasePrice(_symbol);
|
||||
final candles = <Candle>[];
|
||||
|
||||
for (int i = 100; i >= 0; i--) {
|
||||
final time = now.subtract(Duration(
|
||||
minutes: _interval == ChartInterval.min1
|
||||
? i
|
||||
: _interval == ChartInterval.min5
|
||||
? i * 5
|
||||
: _interval == ChartInterval.min15
|
||||
? i * 15
|
||||
: _interval == ChartInterval.hour1
|
||||
? i * 60
|
||||
: _interval == ChartInterval.hour4
|
||||
? i * 240
|
||||
: i * 1440,
|
||||
));
|
||||
|
||||
final random = (i * 17) % 100 / 100;
|
||||
final change = basePrice * 0.02 * (random - 0.5);
|
||||
final open = basePrice + change;
|
||||
final close = open + basePrice * 0.01 * ((i * 13) % 100 / 100 - 0.5);
|
||||
final high = open > close ? open * 1.005 : close * 1.005;
|
||||
final low = open < close ? open * 0.995 : close * 0.995;
|
||||
final volume = 1000 + (i * 37) % 5000;
|
||||
|
||||
candles.add(Candle(
|
||||
timestamp: time.millisecondsSinceEpoch,
|
||||
open: open,
|
||||
high: high,
|
||||
low: low,
|
||||
close: close,
|
||||
volume: volume.toDouble(),
|
||||
));
|
||||
}
|
||||
|
||||
return candles;
|
||||
}
|
||||
|
||||
double _getBasePrice(String symbol) {
|
||||
switch (symbol.toUpperCase()) {
|
||||
case 'BTC':
|
||||
return 42000.0;
|
||||
case 'ETH':
|
||||
return 2200.0;
|
||||
case 'BNB':
|
||||
return 300.0;
|
||||
case 'SOL':
|
||||
return 100.0;
|
||||
case 'XRP':
|
||||
return 0.5;
|
||||
case 'DOGE':
|
||||
return 0.08;
|
||||
default:
|
||||
return 100.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ 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: "我的資產" + "查看全部 >"
|
||||
@@ -121,9 +122,16 @@ class HoldingRow extends StatelessWidget {
|
||||
final accentBgColor = accentColor.withValues(alpha: 0.1);
|
||||
final profitColor = isProfit ? context.appColors.up : context.appColors.down;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md, vertical: 14),
|
||||
child: Row(
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => ChartPage(symbol: coinCode)),
|
||||
);
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md, vertical: 14),
|
||||
child: Row(
|
||||
children: [
|
||||
// Avatar circle with first letter — .pen SJNDJ/EjSIN/3GQ5M
|
||||
Container(
|
||||
@@ -179,6 +187,7 @@ class HoldingRow extends StatelessWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
267
flutter_monisuo/lib/ui/pages/chart/chart_page.dart
Normal file
267
flutter_monisuo/lib/ui/pages/chart/chart_page.dart
Normal file
@@ -0,0 +1,267 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:interactive_chart/interactive_chart.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
import '../../../core/theme/app_spacing.dart';
|
||||
import '../../../core/theme/app_theme.dart';
|
||||
import '../../../core/theme/app_theme_extension.dart';
|
||||
import '../../../providers/chart_provider.dart';
|
||||
|
||||
/// K 线图页面
|
||||
class ChartPage extends StatelessWidget {
|
||||
final String? symbol;
|
||||
|
||||
const ChartPage({super.key, this.symbol});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = context.colors;
|
||||
|
||||
return ChangeNotifierProvider(
|
||||
create: (_) => ChartProvider()..setSymbol(symbol ?? 'BTC'),
|
||||
child: Scaffold(
|
||||
backgroundColor: colorScheme.surface,
|
||||
appBar: AppBar(
|
||||
leading: IconButton(
|
||||
icon: const Icon(LucideIcons.arrowLeft, size: 20),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
title: _buildTitle(context),
|
||||
backgroundColor: colorScheme.surface,
|
||||
elevation: 0,
|
||||
scrolledUnderElevation: 0,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(LucideIcons.settings, size: 20),
|
||||
onPressed: () => _showSettingsSheet(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Consumer<ChartProvider>(
|
||||
builder: (context, provider, _) {
|
||||
if (provider.loading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
if (provider.error != null) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.error_outline,
|
||||
size: 48, color: colorScheme.error),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
Text('加载失败',
|
||||
style: AppTextStyles.bodyLarge(context)),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
TextButton(
|
||||
onPressed: provider.loadCandles,
|
||||
child: const Text('重试'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final candles = provider.candleData;
|
||||
if (candles.length < 3) {
|
||||
return const Center(child: Text('数据不足'));
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
// 时间周期选择
|
||||
_buildIntervalTabs(context, provider),
|
||||
|
||||
// K 线图
|
||||
Expanded(
|
||||
child: InteractiveChart(
|
||||
candles: candles,
|
||||
style: ChartStyle(
|
||||
priceGainColor: context.appColors.up,
|
||||
priceLossColor: context.appColors.down,
|
||||
volumeColor: colorScheme.primary.withValues(alpha: 0.3),
|
||||
priceLabelStyle: TextStyle(
|
||||
fontSize: 11,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
timeLabelStyle: TextStyle(
|
||||
fontSize: 10,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
overlayTextStyle: TextStyle(
|
||||
fontSize: 12,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
volumeHeightFactor: provider.indicators.showVOL ? 0.2 : 0,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTitle(BuildContext context) {
|
||||
return Consumer<ChartProvider>(
|
||||
builder: (context, provider, _) {
|
||||
final colorScheme = context.colors;
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
provider.symbol,
|
||||
style: AppTextStyles.headlineMedium(context).copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.xs),
|
||||
Text(
|
||||
'/USDT',
|
||||
style: AppTextStyles.bodyMedium(context).copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildIntervalTabs(BuildContext context, ChartProvider provider) {
|
||||
final colorScheme = context.colors;
|
||||
|
||||
return Container(
|
||||
height: 44,
|
||||
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.sm),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: colorScheme.outlineVariant.withValues(alpha: 0.2),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: ChartInterval.values.map((interval) {
|
||||
final isSelected = provider.interval == interval;
|
||||
return Expanded(
|
||||
child: InkWell(
|
||||
onTap: () => provider.setInterval(interval),
|
||||
child: Container(
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: isSelected
|
||||
? colorScheme.primary
|
||||
: Colors.transparent,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
interval.label,
|
||||
style: AppTextStyles.labelMedium(context).copyWith(
|
||||
color: isSelected
|
||||
? colorScheme.primary
|
||||
: colorScheme.onSurfaceVariant,
|
||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showSettingsSheet(BuildContext context) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (ctx) => Consumer<ChartProvider>(
|
||||
builder: (ctx, provider, _) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(AppSpacing.lg),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'技术指标',
|
||||
style: AppTextStyles.headlineMedium(context).copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
|
||||
// MA
|
||||
_buildIndicatorSwitch(
|
||||
context: ctx,
|
||||
label: 'MA 移动平均线',
|
||||
value: provider.indicators.showMA,
|
||||
onChanged: (v) => provider.updateIndicators(
|
||||
provider.indicators.copyWith(showMA: v),
|
||||
),
|
||||
),
|
||||
|
||||
// EMA
|
||||
_buildIndicatorSwitch(
|
||||
context: ctx,
|
||||
label: 'EMA 指数移动平均',
|
||||
value: provider.indicators.showEMA,
|
||||
onChanged: (v) => provider.updateIndicators(
|
||||
provider.indicators.copyWith(showEMA: v),
|
||||
),
|
||||
),
|
||||
|
||||
// BOLL
|
||||
_buildIndicatorSwitch(
|
||||
context: ctx,
|
||||
label: 'BOLL 布林带',
|
||||
value: provider.indicators.showBOLL,
|
||||
onChanged: (v) => provider.updateIndicators(
|
||||
provider.indicators.copyWith(showBOLL: v),
|
||||
),
|
||||
),
|
||||
|
||||
// VOL
|
||||
_buildIndicatorSwitch(
|
||||
context: ctx,
|
||||
label: 'VOL 成交量',
|
||||
value: provider.indicators.showVOL,
|
||||
onChanged: (v) => provider.updateIndicators(
|
||||
provider.indicators.copyWith(showVOL: v),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildIndicatorSwitch({
|
||||
required BuildContext context,
|
||||
required String label,
|
||||
required bool value,
|
||||
required ValueChanged<bool> onChanged,
|
||||
}) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: AppSpacing.sm),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(label, style: AppTextStyles.bodyMedium(context)),
|
||||
Switch(value: value, onChanged: onChanged),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user