feat: 添加K线图功能 - interactive_chart + 多币种切换 + 技术指标(MA/EMA/BOLL/VOL)

This commit is contained in:
2026-04-07 16:43:48 +08:00
parent 4b6eb009a9
commit e4d20d5261
90 changed files with 73171 additions and 69980 deletions

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