Files
zhinian_manage/lib/widgets/chart_block.dart

332 lines
9.9 KiB
Dart

import 'package:flutter/material.dart';
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter_markdown/flutter_markdown.dart';
import 'package:markdown/markdown.dart' as md;
import '../theme.dart';
class ChartSpec {
final String type;
final String? title;
final List<String> labels;
final List<double> data;
const ChartSpec({
required this.type,
required this.labels,
required this.data,
this.title,
});
static ChartSpec? tryParse(String raw) {
final lines = raw.split('\n').where((l) => l.trim().isNotEmpty);
final map = <String, String>{};
for (final line in lines) {
final colon = line.indexOf(':');
if (colon < 0) continue;
map[line.substring(0, colon).trim().toLowerCase()] =
line.substring(colon + 1).trim();
}
final type = (map['type'] ?? 'bar').toLowerCase();
final labels = (map['labels'] ?? '')
.split(',')
.map((s) => s.trim())
.where((s) => s.isNotEmpty)
.toList();
final data = (map['data'] ?? '')
.split(',')
.map((s) => double.tryParse(s.trim()))
.whereType<double>()
.toList();
if (labels.isEmpty || data.isEmpty || labels.length != data.length) {
return null;
}
return ChartSpec(type: type, title: map['title'], labels: labels, data: data);
}
}
class ChartBlock extends StatelessWidget {
final ChartSpec spec;
const ChartBlock({super.key, required this.spec});
static const _palette = <Color>[
AppColors.primaryLight,
AppColors.accent,
AppColors.warning,
AppColors.error,
AppColors.success,
AppColors.primaryDark,
AppColors.accentLight,
];
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.symmetric(vertical: 8),
padding: const EdgeInsets.fromLTRB(4, 8, 4, 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (spec.title != null && spec.title!.isNotEmpty) ...[
Text(
spec.title!,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 8),
],
SizedBox(height: 180, child: _buildChart()),
if (spec.type == 'pie') ...[
const SizedBox(height: 8),
_buildLegend(),
],
],
),
);
}
Widget _buildChart() {
switch (spec.type) {
case 'line':
return _scrollable(_buildLine());
case 'pie':
return _buildPie();
case 'bar':
default:
return _scrollable(_buildBar());
}
}
Widget _scrollable(Widget chart) {
const perPoint = 56.0;
final intrinsicWidth = spec.data.length * perPoint;
return LayoutBuilder(
builder: (context, constraints) {
final width = intrinsicWidth > constraints.maxWidth
? intrinsicWidth
: constraints.maxWidth;
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
physics: const BouncingScrollPhysics(),
child: SizedBox(width: width, child: chart),
);
},
);
}
Widget _buildBar() {
final maxY = spec.data.reduce((a, b) => a > b ? a : b);
return BarChart(
BarChartData(
alignment: BarChartAlignment.spaceAround,
maxY: maxY * 1.2,
barTouchData: BarTouchData(enabled: false),
gridData: FlGridData(
show: true,
drawVerticalLine: false,
getDrawingHorizontalLine: (_) =>
FlLine(color: AppColors.divider, strokeWidth: 0.5),
),
borderData: FlBorderData(show: false),
titlesData: FlTitlesData(
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
rightTitles:
const AxisTitles(sideTitles: SideTitles(showTitles: false)),
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 32,
getTitlesWidget: (v, _) => Text(
v.toInt().toString(),
style: TextStyle(fontSize: 10, color: AppColors.textTertiary),
),
),
),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 22,
getTitlesWidget: (v, _) {
final idx = v.toInt();
if (idx < 0 || idx >= spec.labels.length) {
return const SizedBox();
}
return Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
spec.labels[idx],
style:
TextStyle(fontSize: 10, color: AppColors.textSecondary),
),
);
},
),
),
),
barGroups: [
for (var i = 0; i < spec.data.length; i++)
BarChartGroupData(
x: i,
barRods: [
BarChartRodData(
toY: spec.data[i],
color: _palette[i % _palette.length],
width: 18,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(4),
),
),
],
),
],
),
);
}
Widget _buildLine() {
final maxY = spec.data.reduce((a, b) => a > b ? a : b);
final minY = spec.data.reduce((a, b) => a < b ? a : b);
final pad = (maxY - minY).abs() * 0.2 + 1;
return LineChart(
LineChartData(
minY: minY - pad,
maxY: maxY + pad,
gridData: FlGridData(
show: true,
drawVerticalLine: false,
getDrawingHorizontalLine: (_) =>
FlLine(color: AppColors.divider, strokeWidth: 0.5),
),
borderData: FlBorderData(show: false),
titlesData: FlTitlesData(
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
rightTitles:
const AxisTitles(sideTitles: SideTitles(showTitles: false)),
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 32,
getTitlesWidget: (v, _) => Text(
v.toInt().toString(),
style: TextStyle(fontSize: 10, color: AppColors.textTertiary),
),
),
),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 22,
getTitlesWidget: (v, _) {
final idx = v.toInt();
if (idx < 0 || idx >= spec.labels.length) {
return const SizedBox();
}
return Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
spec.labels[idx],
style:
TextStyle(fontSize: 10, color: AppColors.textSecondary),
),
);
},
),
),
),
lineBarsData: [
LineChartBarData(
spots: [
for (var i = 0; i < spec.data.length; i++)
FlSpot(i.toDouble(), spec.data[i]),
],
isCurved: true,
color: AppColors.primaryLight,
barWidth: 2.5,
dotData: FlDotData(
show: true,
getDotPainter: (spot, _, __, ___) => FlDotCirclePainter(
radius: 3,
color: AppColors.primaryLight,
strokeWidth: 1.5,
strokeColor: AppColors.surface,
),
),
belowBarData: BarAreaData(
show: true,
color: AppColors.primaryLight.withOpacity(0.15),
),
),
],
),
);
}
Widget _buildPie() {
final total = spec.data.fold<double>(0, (a, b) => a + b);
return PieChart(
PieChartData(
sectionsSpace: 2,
centerSpaceRadius: 28,
sections: [
for (var i = 0; i < spec.data.length; i++)
PieChartSectionData(
value: spec.data[i],
color: _palette[i % _palette.length],
radius: 50,
title: total == 0
? ''
: '${(spec.data[i] / total * 100).toStringAsFixed(0)}%',
titleStyle: const TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.w600,
),
),
],
),
);
}
Widget _buildLegend() {
return Wrap(
spacing: 12,
runSpacing: 4,
children: [
for (var i = 0; i < spec.labels.length; i++)
Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 10,
height: 10,
decoration: BoxDecoration(
color: _palette[i % _palette.length],
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(width: 4),
Text(
'${spec.labels[i]} ${spec.data[i].toInt()}',
style: TextStyle(fontSize: 11, color: AppColors.textSecondary),
),
],
),
],
);
}
}
class ChartCodeBuilder extends MarkdownElementBuilder {
@override
Widget? visitElementAfter(md.Element element, TextStyle? preferredStyle) {
final lang = element.attributes['class'] ?? '';
if (!lang.contains('language-chart')) return null;
final raw = element.textContent;
final spec = ChartSpec.tryParse(raw);
if (spec == null) return null;
return ChartBlock(spec: spec);
}
}