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 labels; final List 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 = {}; 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() .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 = [ 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(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); } }