找回密码
 立即注册
首页 业界区 业界 Flutter数据可视化:fl_chart图表库的高级应用 ...

Flutter数据可视化:fl_chart图表库的高级应用

凤清昶 前天 07:09
Flutter数据可视化:fl_chart图表库的高级应用

本文基于BeeCount(蜜蜂记账)项目的实际开发经验,深入探讨如何使用fl_chart构建美观、交互式的财务数据可视化图表。
项目背景

BeeCount(蜜蜂记账)是一款开源、简洁、无广告的个人记账应用。所有财务数据完全由用户掌控,支持本地存储和可选的云端同步,确保数据绝对安全。
引言

数据可视化是现代应用的重要特性,特别是对于财务管理类应用。用户需要直观地了解自己的收支状况、消费趋势和资产分布。优秀的数据可视化不仅能帮助用户更好地理解数据,还能提升应用的专业性和用户粘性。
fl_chart是Flutter生态中最受欢迎的图表库之一,它提供了丰富的图表类型、流畅的动画效果和灵活的自定义选项。在BeeCount项目中,我们使用fl_chart构建了完整的财务数据分析功能,包括趋势图、饼图、柱状图等多种图表类型。
fl_chart核心特性

丰富的图表类型


  • 线性图(LineChart): 展示数据趋势变化
  • 柱状图(BarChart): 对比不同类别数据
  • 饼图(PieChart): 显示数据占比分布
  • 散点图(ScatterChart): 展示数据相关性
  • 雷达图(RadarChart): 多维度数据对比
强大的交互能力


  • 触摸交互: 点击、长按、滑动等手势支持
  • 动态更新: 数据变化时的流畅动画
  • 自定义样式: 完全可定制的视觉效果
  • 响应式设计: 适配不同屏幕尺寸
财务数据分析架构

数据模型设计
  1. // 统计数据基类
  2. abstract class ChartData {
  3.   final DateTime date;
  4.   final double value;
  5.   final String label;
  6.   const ChartData({
  7.     required this.date,
  8.     required this.value,
  9.     required this.label,
  10.   });
  11. }
  12. // 日收支统计
  13. class DailyStats extends ChartData {
  14.   final double income;
  15.   final double expense;
  16.   final double net;
  17.   const DailyStats({
  18.     required DateTime date,
  19.     required this.income,
  20.     required this.expense,
  21.     required this.net,
  22.   }) : super(
  23.           date: date,
  24.           value: net,
  25.           label: '',
  26.         );
  27.   factory DailyStats.fromTransaction(List<Transaction> transactions, DateTime date) {
  28.     double income = 0;
  29.     double expense = 0;
  30.     for (final tx in transactions) {
  31.       if (isSameDay(tx.happenedAt, date)) {
  32.         switch (tx.type) {
  33.           case 'income':
  34.             income += tx.amount;
  35.             break;
  36.           case 'expense':
  37.             expense += tx.amount;
  38.             break;
  39.         }
  40.       }
  41.     }
  42.     return DailyStats(
  43.       date: date,
  44.       income: income,
  45.       expense: expense,
  46.       net: income - expense,
  47.     );
  48.   }
  49. }
  50. // 分类统计
  51. class CategoryStats extends ChartData {
  52.   final String categoryName;
  53.   final int transactionCount;
  54.   final Color color;
  55.   const CategoryStats({
  56.     required DateTime date,
  57.     required double value,
  58.     required this.categoryName,
  59.     required this.transactionCount,
  60.     required this.color,
  61.   }) : super(
  62.           date: date,
  63.           value: value,
  64.           label: categoryName,
  65.         );
  66. }
  67. // 月度趋势
  68. class MonthlyTrend extends ChartData {
  69.   final int year;
  70.   final int month;
  71.   final double income;
  72.   final double expense;
  73.   const MonthlyTrend({
  74.     required this.year,
  75.     required this.month,
  76.     required this.income,
  77.     required this.expense,
  78.   }) : super(
  79.           date: DateTime(year, month),
  80.           value: income - expense,
  81.           label: '$year年$month月',
  82.         );
  83. }
复制代码
数据处理服务
  1. class AnalyticsService {
  2.   final BeeRepository repository;
  3.   AnalyticsService(this.repository);
  4.   // 获取指定时间范围的日统计数据
  5.   Future<List<DailyStats>> getDailyStats({
  6.     required int ledgerId,
  7.     required DateTimeRange range,
  8.   }) async {
  9.     final transactions = await repository.getTransactionsInRange(
  10.       ledgerId: ledgerId,
  11.       range: range,
  12.     );
  13.     final Map<DateTime, List<Transaction>> groupedByDate = {};
  14.     for (final tx in transactions) {
  15.       final date = DateTime(tx.happenedAt.year, tx.happenedAt.month, tx.happenedAt.day);
  16.       groupedByDate.putIfAbsent(date, () => []).add(tx);
  17.     }
  18.     final List<DailyStats> result = [];
  19.     DateTime current = DateTime(range.start.year, range.start.month, range.start.day);
  20.     final end = DateTime(range.end.year, range.end.month, range.end.day);
  21.     while (!current.isAfter(end)) {
  22.       final dayTransactions = groupedByDate[current] ?? [];
  23.       result.add(DailyStats.fromTransaction(dayTransactions, current));
  24.       current = current.add(const Duration(days: 1));
  25.     }
  26.     return result;
  27.   }
  28.   // 获取分类统计数据
  29.   Future<List<CategoryStats>> getCategoryStats({
  30.     required int ledgerId,
  31.     required DateTimeRange range,
  32.     required String type, // 'income' or 'expense'
  33.   }) async {
  34.     final transactions = await repository.getCategoryStatsInRange(
  35.       ledgerId: ledgerId,
  36.       range: range,
  37.       type: type,
  38.     );
  39.     final Map<String, CategoryStatsData> categoryMap = {};
  40.    
  41.     for (final tx in transactions) {
  42.       final categoryName = tx.categoryName ?? '未分类';
  43.       final existing = categoryMap[categoryName];
  44.       
  45.       if (existing == null) {
  46.         categoryMap[categoryName] = CategoryStatsData(
  47.           categoryName: categoryName,
  48.           totalAmount: tx.amount,
  49.           transactionCount: 1,
  50.           color: _getCategoryColor(categoryName),
  51.         );
  52.       } else {
  53.         existing.totalAmount += tx.amount;
  54.         existing.transactionCount += 1;
  55.       }
  56.     }
  57.     return categoryMap.values
  58.         .map((data) => CategoryStats(
  59.               date: range.start,
  60.               value: data.totalAmount,
  61.               categoryName: data.categoryName,
  62.               transactionCount: data.transactionCount,
  63.               color: data.color,
  64.             ))
  65.         .toList()
  66.       ..sort((a, b) => b.value.compareTo(a.value));
  67.   }
  68.   // 获取月度趋势数据
  69.   Future<List<MonthlyTrend>> getMonthlyTrends({
  70.     required int ledgerId,
  71.     required int year,
  72.   }) async {
  73.     final List<MonthlyTrend> trends = [];
  74.     for (int month = 1; month <= 12; month++) {
  75.       final range = DateTimeRange(
  76.         start: DateTime(year, month, 1),
  77.         end: DateTime(year, month + 1, 1).subtract(const Duration(days: 1)),
  78.       );
  79.       final monthStats = await repository.getMonthStats(
  80.         ledgerId: ledgerId,
  81.         range: range,
  82.       );
  83.       trends.add(MonthlyTrend(
  84.         year: year,
  85.         month: month,
  86.         income: monthStats.income,
  87.         expense: monthStats.expense,
  88.       ));
  89.     }
  90.     return trends;
  91.   }
  92.   Color _getCategoryColor(String categoryName) {
  93.     // 为不同分类分配固定颜色
  94.     final colors = [
  95.       Colors.red.shade300,
  96.       Colors.blue.shade300,
  97.       Colors.green.shade300,
  98.       Colors.orange.shade300,
  99.       Colors.purple.shade300,
  100.       Colors.teal.shade300,
  101.       Colors.amber.shade300,
  102.       Colors.indigo.shade300,
  103.     ];
  104.    
  105.     final index = categoryName.hashCode % colors.length;
  106.     return colors[index.abs()];
  107.   }
  108. }
复制代码
月度对比柱状图

响应式柱状图组件
  1. class IncomeExpenseTrendChart extends ConsumerWidget {
  2.   final DateTimeRange dateRange;
  3.   final int ledgerId;
  4.   const IncomeExpenseTrendChart({
  5.     Key? key,
  6.     required this.dateRange,
  7.     required this.ledgerId,
  8.   }) : super(key: key);
  9.   @override
  10.   Widget build(BuildContext context, WidgetRef ref) {
  11.     final dailyStatsAsync = ref.watch(dailyStatsProvider(DailyStatsParams(
  12.       ledgerId: ledgerId,
  13.       range: dateRange,
  14.     )));
  15.     return Card(
  16.       child: Padding(
  17.         padding: const EdgeInsets.all(16),
  18.         child: Column(
  19.           crossAxisAlignment: CrossAxisAlignment.start,
  20.           children: [
  21.             Row(
  22.               mainAxisAlignment: MainAxisAlignment.spaceBetween,
  23.               children: [
  24.                 Text(
  25.                   '收支趋势',
  26.                   style: Theme.of(context).textTheme.titleLarge,
  27.                 ),
  28.                 PopupMenuButton<String>(
  29.                   onSelected: (value) {
  30.                     // 处理时间范围选择
  31.                   },
  32.                   itemBuilder: (context) => [
  33.                     const PopupMenuItem(value: '7d', child: Text('最近7天')),
  34.                     const PopupMenuItem(value: '30d', child: Text('最近30天')),
  35.                     const PopupMenuItem(value: '90d', child: Text('最近90天')),
  36.                   ],
  37.                   child: const Icon(Icons.more_vert),
  38.                 ),
  39.               ],
  40.             ),
  41.             const SizedBox(height: 16),
  42.             
  43.             SizedBox(
  44.               height: 280,
  45.               child: dailyStatsAsync.when(
  46.                 data: (stats) => _buildChart(context, stats),
  47.                 loading: () => const Center(child: CircularProgressIndicator()),
  48.                 error: (error, _) => Center(
  49.                   child: Text('加载失败: $error'),
  50.                 ),
  51.               ),
  52.             ),
  53.           ],
  54.         ),
  55.       ),
  56.     );
  57.   }
  58.   Widget _buildChart(BuildContext context, List<DailyStats> stats) {
  59.     if (stats.isEmpty) {
  60.       return const Center(
  61.         child: Text('暂无数据'),
  62.       );
  63.     }
  64.     final theme = Theme.of(context);
  65.     final colors = BeeTheme.colorsOf(context);
  66.     return LineChart(
  67.       LineChartData(
  68.         gridData: FlGridData(
  69.           show: true,
  70.           drawHorizontalLine: true,
  71.           drawVerticalLine: false,
  72.           horizontalInterval: _calculateInterval(stats),
  73.           getDrawingHorizontalLine: (value) => FlLine(
  74.             color: theme.colorScheme.outline.withOpacity(0.2),
  75.             strokeWidth: 1,
  76.           ),
  77.         ),
  78.         
  79.         titlesData: FlTitlesData(
  80.           show: true,
  81.           rightTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)),
  82.           topTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)),
  83.          
  84.           bottomTitles: AxisTitles(
  85.             sideTitles: SideTitles(
  86.               showTitles: true,
  87.               reservedSize: 30,
  88.               interval: _getBottomInterval(stats),
  89.               getTitlesWidget: (value, meta) => _buildBottomTitle(
  90.                 context,
  91.                 stats,
  92.                 value.toInt(),
  93.               ),
  94.             ),
  95.           ),
  96.          
  97.           leftTitles: AxisTitles(
  98.             sideTitles: SideTitles(
  99.               showTitles: true,
  100.               interval: _calculateInterval(stats),
  101.               reservedSize: 60,
  102.               getTitlesWidget: (value, meta) => _buildLeftTitle(
  103.                 context,
  104.                 value,
  105.               ),
  106.             ),
  107.           ),
  108.         ),
  109.         
  110.         borderData: FlBorderData(show: false),
  111.         
  112.         minX: 0,
  113.         maxX: stats.length.toDouble() - 1,
  114.         minY: _getMinY(stats),
  115.         maxY: _getMaxY(stats),
  116.         
  117.         lineBarsData: [
  118.           // 收入线
  119.           LineChartBarData(
  120.             spots: _createSpots(stats, (stat) => stat.income),
  121.             isCurved: true,
  122.             color: colors.income,
  123.             barWidth: 3,
  124.             isStrokeCapRound: true,
  125.             dotData: FlDotData(
  126.               show: true,
  127.               getDotPainter: (spot, percent, barData, index) =>
  128.                   FlDotCirclePainter(
  129.                 radius: 4,
  130.                 color: colors.income,
  131.                 strokeWidth: 2,
  132.                 strokeColor: Colors.white,
  133.               ),
  134.             ),
  135.             belowBarData: BarAreaData(
  136.               show: true,
  137.               color: colors.income.withOpacity(0.1),
  138.             ),
  139.           ),
  140.          
  141.           // 支出线
  142.           LineChartBarData(
  143.             spots: _createSpots(stats, (stat) => stat.expense),
  144.             isCurved: true,
  145.             color: colors.expense,
  146.             barWidth: 3,
  147.             isStrokeCapRound: true,
  148.             dotData: FlDotData(
  149.               show: true,
  150.               getDotPainter: (spot, percent, barData, index) =>
  151.                   FlDotCirclePainter(
  152.                 radius: 4,
  153.                 color: colors.expense,
  154.                 strokeWidth: 2,
  155.                 strokeColor: Colors.white,
  156.               ),
  157.             ),
  158.           ),
  159.         ],
  160.         
  161.         lineTouchData: LineTouchData(
  162.           enabled: true,
  163.           touchTooltipData: LineTouchTooltipData(
  164.             tooltipBgColor: theme.colorScheme.surface,
  165.             tooltipBorder: BorderSide(
  166.               color: theme.colorScheme.outline,
  167.             ),
  168.             tooltipRoundedRadius: 8,
  169.             getTooltipItems: (touchedSpots) => _buildTooltipItems(
  170.               context,
  171.               touchedSpots,
  172.               stats,
  173.               colors,
  174.             ),
  175.           ),
  176.           touchCallback: (FlTouchEvent event, LineTouchResponse? touchResponse) {
  177.             // 处理触摸事件
  178.             if (event is FlTapUpEvent && touchResponse?.lineBarSpots != null) {
  179.               final spot = touchResponse!.lineBarSpots!.first;
  180.               final dayStats = stats[spot.spotIndex];
  181.               _showDayDetails(context, dayStats);
  182.             }
  183.           },
  184.         ),
  185.       ),
  186.     );
  187.   }
  188.   List<FlSpot> _createSpots(List<DailyStats> stats, double Function(DailyStats) getValue) {
  189.     return stats.asMap().entries.map((entry) {
  190.       return FlSpot(entry.key.toDouble(), getValue(entry.value));
  191.     }).toList();
  192.   }
  193.   double _calculateInterval(List<DailyStats> stats) {
  194.     if (stats.isEmpty) return 100;
  195.    
  196.     final maxValue = stats
  197.         .map((s) => math.max(s.income, s.expense))
  198.         .reduce(math.max);
  199.    
  200.     if (maxValue <= 100) return 50;
  201.     if (maxValue <= 1000) return 200;
  202.     if (maxValue <= 10000) return 2000;
  203.     return 5000;
  204.   }
  205.   double _getBottomInterval(List<DailyStats> stats) {
  206.     if (stats.length <= 7) return 1;
  207.     if (stats.length <= 14) return 2;
  208.     if (stats.length <= 30) return 5;
  209.     return 10;
  210.   }
  211.   Widget _buildBottomTitle(BuildContext context, List<DailyStats> stats, int index) {
  212.     if (index < 0 || index >= stats.length) return const SizedBox.shrink();
  213.    
  214.     final date = stats[index].date;
  215.     final text = DateFormat('MM/dd').format(date);
  216.    
  217.     return SideTitleWidget(
  218.       axisSide: meta.axisSide,
  219.       child: Text(
  220.         text,
  221.         style: TextStyle(
  222.           color: Theme.of(context).colorScheme.onSurfaceVariant,
  223.           fontSize: 12,
  224.         ),
  225.       ),
  226.     );
  227.   }
  228.   Widget _buildLeftTitle(BuildContext context, double value) {
  229.     return Text(
  230.       _formatAmount(value),
  231.       style: TextStyle(
  232.         color: Theme.of(context).colorScheme.onSurfaceVariant,
  233.         fontSize: 12,
  234.       ),
  235.     );
  236.   }
  237.   String _formatAmount(double amount) {
  238.     if (amount.abs() >= 10000) {
  239.       return '${(amount / 10000).toStringAsFixed(1)}万';
  240.     }
  241.     return amount.toStringAsFixed(0);
  242.   }
  243.   List<LineTooltipItem?> _buildTooltipItems(
  244.     BuildContext context,
  245.     List<LineBarSpot> touchedSpots,
  246.     List<DailyStats> stats,
  247.     BeeColors colors,
  248.   ) {
  249.     return touchedSpots.map((LineBarSpot touchedSpot) {
  250.       const textStyle = TextStyle(
  251.         color: Colors.white,
  252.         fontWeight: FontWeight.bold,
  253.         fontSize: 14,
  254.       );
  255.       
  256.       final dayStats = stats[touchedSpot.spotIndex];
  257.       final date = DateFormat('MM月dd日').format(dayStats.date);
  258.       
  259.       if (touchedSpot.barIndex == 0) {
  260.         // 收入线
  261.         return LineTooltipItem(
  262.           '$date\n收入: ${dayStats.income.toStringAsFixed(2)}',
  263.           textStyle.copyWith(color: colors.income),
  264.         );
  265.       } else {
  266.         // 支出线
  267.         return LineTooltipItem(
  268.           '$date\n支出: ${dayStats.expense.toStringAsFixed(2)}',
  269.           textStyle.copyWith(color: colors.expense),
  270.         );
  271.       }
  272.     }).toList();
  273.   }
  274.   void _showDayDetails(BuildContext context, DailyStats dayStats) {
  275.     showModalBottomSheet(
  276.       context: context,
  277.       builder: (context) => DayDetailsSheet(dayStats: dayStats),
  278.     );
  279.   }
  280.   double _getMinY(List<DailyStats> stats) {
  281.     if (stats.isEmpty) return 0;
  282.     return math.min(0, stats.map((s) => math.min(s.income, s.expense)).reduce(math.min)) * 1.1;
  283.   }
  284.   double _getMaxY(List<DailyStats> stats) {
  285.     if (stats.isEmpty) return 100;
  286.     return stats.map((s) => math.max(s.income, s.expense)).reduce(math.max) * 1.1;
  287.   }
  288. }
复制代码
响应式数据更新
  1. class CategoryExpensePieChart extends ConsumerStatefulWidget {
  2.   final DateTimeRange dateRange;
  3.   final int ledgerId;
  4.   const CategoryExpensePieChart({
  5.     Key? key,
  6.     required this.dateRange,
  7.     required this.ledgerId,
  8.   }) : super(key: key);
  9.   @override
  10.   ConsumerState<CategoryExpensePieChart> createState() => _CategoryExpensePieChartState();
  11. }
  12. class _CategoryExpensePieChartState extends ConsumerState<CategoryExpensePieChart>
  13.     with SingleTickerProviderStateMixin {
  14.   int touchedIndex = -1;
  15.   late AnimationController _animationController;
  16.   late Animation<double> _animation;
  17.   @override
  18.   void initState() {
  19.     super.initState();
  20.     _animationController = AnimationController(
  21.       duration: const Duration(milliseconds: 600),
  22.       vsync: this,
  23.     );
  24.     _animation = CurvedAnimation(
  25.       parent: _animationController,
  26.       curve: Curves.easeInOut,
  27.     );
  28.     _animationController.forward();
  29.   }
  30.   @override
  31.   void dispose() {
  32.     _animationController.dispose();
  33.     super.dispose();
  34.   }
  35.   @override
  36.   Widget build(BuildContext context) {
  37.     final categoryStatsAsync = ref.watch(categoryStatsProvider(CategoryStatsParams(
  38.       ledgerId: widget.ledgerId,
  39.       range: widget.dateRange,
  40.       type: 'expense',
  41.     )));
  42.     return Card(
  43.       child: Padding(
  44.         padding: const EdgeInsets.all(16),
  45.         child: Column(
  46.           crossAxisAlignment: CrossAxisAlignment.start,
  47.           children: [
  48.             Text(
  49.               '支出分类',
  50.               style: Theme.of(context).textTheme.titleLarge,
  51.             ),
  52.             const SizedBox(height: 16),
  53.             
  54.             SizedBox(
  55.               height: 300,
  56.               child: categoryStatsAsync.when(
  57.                 data: (stats) => _buildChart(context, stats),
  58.                 loading: () => const Center(child: CircularProgressIndicator()),
  59.                 error: (error, _) => Center(
  60.                   child: Text('加载失败: $error'),
  61.                 ),
  62.               ),
  63.             ),
  64.             
  65.             const SizedBox(height: 16),
  66.             categoryStatsAsync.maybeWhen(
  67.               data: (stats) => _buildLegend(context, stats),
  68.               orElse: () => const SizedBox.shrink(),
  69.             ),
  70.           ],
  71.         ),
  72.       ),
  73.     );
  74.   }
  75.   Widget _buildChart(BuildContext context, List<CategoryStats> stats) {
  76.     if (stats.isEmpty) {
  77.       return const Center(
  78.         child: Text('暂无支出数据'),
  79.       );
  80.     }
  81.     // 只显示前8个分类,其余归为"其他"
  82.     final displayStats = _prepareDisplayStats(stats);
  83.     final total = displayStats.fold(0.0, (sum, stat) => sum + stat.value);
  84.     return AnimatedBuilder(
  85.       animation: _animation,
  86.       builder: (context, child) {
  87.         return PieChart(
  88.           PieChartData(
  89.             pieTouchData: PieTouchData(
  90.               touchCallback: (FlTouchEvent event, pieTouchResponse) {
  91.                 setState(() {
  92.                   if (!event.isInterestedForInteractions ||
  93.                       pieTouchResponse == null ||
  94.                       pieTouchResponse.touchedSection == null) {
  95.                     touchedIndex = -1;
  96.                     return;
  97.                   }
  98.                   touchedIndex = pieTouchResponse.touchedSection!.touchedSectionIndex;
  99.                 });
  100.               },
  101.             ),
  102.             
  103.             borderData: FlBorderData(show: false),
  104.             sectionsSpace: 2,
  105.             centerSpaceRadius: 60,
  106.             
  107.             sections: displayStats.asMap().entries.map((entry) {
  108.               final index = entry.key;
  109.               final stat = entry.value;
  110.               final isTouched = index == touchedIndex;
  111.               final percentage = (stat.value / total * 100);
  112.               
  113.               return PieChartSectionData(
  114.                 color: stat.color,
  115.                 value: stat.value,
  116.                 title: '${percentage.toStringAsFixed(1)}%',
  117.                 radius: (isTouched ? 110.0 : 100.0) * _animation.value,
  118.                 titleStyle: TextStyle(
  119.                   fontSize: isTouched ? 16.0 : 14.0,
  120.                   fontWeight: FontWeight.bold,
  121.                   color: Colors.white,
  122.                   shadows: [
  123.                     Shadow(
  124.                       color: Colors.black.withOpacity(0.5),
  125.                       blurRadius: 2,
  126.                     ),
  127.                   ],
  128.                 ),
  129.                 badgeWidget: isTouched ? _buildBadge(stat) : null,
  130.                 badgePositionPercentageOffset: 1.2,
  131.               );
  132.             }).toList(),
  133.           ),
  134.         );
  135.       },
  136.     );
  137.   }
  138.   Widget _buildBadge(CategoryStats stat) {
  139.     return Container(
  140.       padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
  141.       decoration: BoxDecoration(
  142.         color: stat.color,
  143.         borderRadius: BorderRadius.circular(12),
  144.         border: Border.all(color: Colors.white, width: 2),
  145.         boxShadow: [
  146.           BoxShadow(
  147.             color: Colors.black.withOpacity(0.2),
  148.             blurRadius: 4,
  149.             offset: const Offset(0, 2),
  150.           ),
  151.         ],
  152.       ),
  153.       child: Text(
  154.         '¥${stat.value.toStringAsFixed(0)}',
  155.         style: const TextStyle(
  156.           color: Colors.white,
  157.           fontWeight: FontWeight.bold,
  158.           fontSize: 12,
  159.         ),
  160.       ),
  161.     );
  162.   }
  163.   Widget _buildLegend(BuildContext context, List<CategoryStats> stats) {
  164.     final displayStats = _prepareDisplayStats(stats);
  165.    
  166.     return Column(
  167.       children: displayStats.asMap().entries.map((entry) {
  168.         final index = entry.key;
  169.         final stat = entry.value;
  170.         final isHighlighted = index == touchedIndex;
  171.         
  172.         return AnimatedContainer(
  173.           duration: const Duration(milliseconds: 200),
  174.           margin: const EdgeInsets.symmetric(vertical: 2),
  175.           padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
  176.           decoration: BoxDecoration(
  177.             color: isHighlighted
  178.                 ? stat.color.withOpacity(0.1)
  179.                 : Colors.transparent,
  180.             borderRadius: BorderRadius.circular(8),
  181.             border: isHighlighted
  182.                 ? Border.all(color: stat.color.withOpacity(0.3))
  183.                 : null,
  184.           ),
  185.           child: Row(
  186.             children: [
  187.               Container(
  188.                 width: 16,
  189.                 height: 16,
  190.                 decoration: BoxDecoration(
  191.                   color: stat.color,
  192.                   shape: BoxShape.circle,
  193.                 ),
  194.               ),
  195.               const SizedBox(width: 12),
  196.               
  197.               Expanded(
  198.                 child: Text(
  199.                   stat.categoryName,
  200.                   style: TextStyle(
  201.                     fontWeight: isHighlighted
  202.                         ? FontWeight.w600
  203.                         : FontWeight.normal,
  204.                   ),
  205.                 ),
  206.               ),
  207.               
  208.               Column(
  209.                 crossAxisAlignment: CrossAxisAlignment.end,
  210.                 children: [
  211.                   Text(
  212.                     '¥${stat.value.toStringAsFixed(2)}',
  213.                     style: TextStyle(
  214.                       fontWeight: FontWeight.w600,
  215.                       color: isHighlighted
  216.                           ? stat.color
  217.                           : Theme.of(context).colorScheme.onSurface,
  218.                     ),
  219.                   ),
  220.                   Text(
  221.                     '${stat.transactionCount}笔',
  222.                     style: Theme.of(context).textTheme.bodySmall?.copyWith(
  223.                       color: Theme.of(context).colorScheme.onSurfaceVariant,
  224.                     ),
  225.                   ),
  226.                 ],
  227.               ),
  228.             ],
  229.           ),
  230.         );
  231.       }).toList(),
  232.     );
  233.   }
  234.   List<CategoryStats> _prepareDisplayStats(List<CategoryStats> stats) {
  235.     if (stats.length <= 8) return stats;
  236.     final topStats = stats.take(7).toList();
  237.     final othersValue = stats.skip(7).fold(0.0, (sum, stat) => sum + stat.value);
  238.     final othersCount = stats.skip(7).fold(0, (sum, stat) => sum + stat.transactionCount);
  239.     if (othersValue > 0) {
  240.       topStats.add(CategoryStats(
  241.         date: DateTime.now(),
  242.         value: othersValue,
  243.         categoryName: '其他',
  244.         transactionCount: othersCount,
  245.         color: Colors.grey.shade400,
  246.       ));
  247.     }
  248.     return topStats;
  249.   }
  250. }
复制代码
图表交互增强

手势操作支持
  1. class MonthlyComparisonBarChart extends ConsumerWidget {
  2.   final int year;
  3.   final int ledgerId;
  4.   const MonthlyComparisonBarChart({
  5.     Key? key,
  6.     required this.year,
  7.     required this.ledgerId,
  8.   }) : super(key: key);
  9.   @override
  10.   Widget build(BuildContext context, WidgetRef ref) {
  11.     final monthlyTrendsAsync = ref.watch(monthlyTrendsProvider(MonthlyTrendsParams(
  12.       ledgerId: ledgerId,
  13.       year: year,
  14.     )));
  15.     return Card(
  16.       child: Padding(
  17.         padding: const EdgeInsets.all(16),
  18.         child: Column(
  19.           crossAxisAlignment: CrossAxisAlignment.start,
  20.           children: [
  21.             Row(
  22.               mainAxisAlignment: MainAxisAlignment.spaceBetween,
  23.               children: [
  24.                 Text(
  25.                   '$year年月度对比',
  26.                   style: Theme.of(context).textTheme.titleLarge,
  27.                 ),
  28.                 Row(
  29.                   children: [
  30.                     _buildLegendItem(context, '收入', BeeTheme.colorsOf(context).income),
  31.                     const SizedBox(width: 16),
  32.                     _buildLegendItem(context, '支出', BeeTheme.colorsOf(context).expense),
  33.                   ],
  34.                 ),
  35.               ],
  36.             ),
  37.             const SizedBox(height: 16),
  38.             
  39.             SizedBox(
  40.               height: 300,
  41.               child: monthlyTrendsAsync.when(
  42.                 data: (trends) => _buildChart(context, trends),
  43.                 loading: () => const Center(child: CircularProgressIndicator()),
  44.                 error: (error, _) => Center(
  45.                   child: Text('加载失败: $error'),
  46.                 ),
  47.               ),
  48.             ),
  49.           ],
  50.         ),
  51.       ),
  52.     );
  53.   }
  54.   Widget _buildLegendItem(BuildContext context, String label, Color color) {
  55.     return Row(
  56.       mainAxisSize: MainAxisSize.min,
  57.       children: [
  58.         Container(
  59.           width: 12,
  60.           height: 12,
  61.           decoration: BoxDecoration(
  62.             color: color,
  63.             borderRadius: BorderRadius.circular(2),
  64.           ),
  65.         ),
  66.         const SizedBox(width: 6),
  67.         Text(
  68.           label,
  69.           style: Theme.of(context).textTheme.bodySmall,
  70.         ),
  71.       ],
  72.     );
  73.   }
  74.   Widget _buildChart(BuildContext context, List<MonthlyTrend> trends) {
  75.     if (trends.isEmpty) {
  76.       return const Center(
  77.         child: Text('暂无数据'),
  78.       );
  79.     }
  80.     final theme = Theme.of(context);
  81.     final colors = BeeTheme.colorsOf(context);
  82.     final maxValue = trends
  83.         .map((t) => math.max(t.income, t.expense))
  84.         .reduce(math.max);
  85.     return BarChart(
  86.       BarChartData(
  87.         alignment: BarChartAlignment.spaceAround,
  88.         maxY: maxValue * 1.2,
  89.         
  90.         gridData: FlGridData(
  91.           show: true,
  92.           drawHorizontalLine: true,
  93.           drawVerticalLine: false,
  94.           horizontalInterval: _calculateInterval(maxValue),
  95.           getDrawingHorizontalLine: (value) => FlLine(
  96.             color: theme.colorScheme.outline.withOpacity(0.2),
  97.             strokeWidth: 1,
  98.           ),
  99.         ),
  100.         
  101.         titlesData: FlTitlesData(
  102.           show: true,
  103.           rightTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)),
  104.           topTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)),
  105.          
  106.           bottomTitles: AxisTitles(
  107.             sideTitles: SideTitles(
  108.               showTitles: true,
  109.               getTitlesWidget: (value, meta) {
  110.                 final month = value.toInt() + 1;
  111.                 return SideTitleWidget(
  112.                   axisSide: meta.axisSide,
  113.                   child: Text(
  114.                     '${month}月',
  115.                     style: TextStyle(
  116.                       color: theme.colorScheme.onSurfaceVariant,
  117.                       fontSize: 12,
  118.                     ),
  119.                   ),
  120.                 );
  121.               },
  122.             ),
  123.           ),
  124.          
  125.           leftTitles: AxisTitles(
  126.             sideTitles: SideTitles(
  127.               showTitles: true,
  128.               reservedSize: 60,
  129.               interval: _calculateInterval(maxValue),
  130.               getTitlesWidget: (value, meta) {
  131.                 return Text(
  132.                   _formatAmount(value),
  133.                   style: TextStyle(
  134.                     color: theme.colorScheme.onSurfaceVariant,
  135.                     fontSize: 12,
  136.                   ),
  137.                 );
  138.               },
  139.             ),
  140.           ),
  141.         ),
  142.         
  143.         borderData: FlBorderData(show: false),
  144.         
  145.         barGroups: trends.asMap().entries.map((entry) {
  146.           final index = entry.key;
  147.           final trend = entry.value;
  148.          
  149.           return BarChartGroupData(
  150.             x: index,
  151.             barRods: [
  152.               BarChartRodData(
  153.                 toY: trend.income,
  154.                 color: colors.income,
  155.                 width: 12,
  156.                 borderRadius: const BorderRadius.vertical(
  157.                   top: Radius.circular(4),
  158.                 ),
  159.                 backDrawRodData: BackgroundBarChartRodData(
  160.                   show: true,
  161.                   toY: maxValue * 1.2,
  162.                   color: theme.colorScheme.surfaceVariant.withOpacity(0.3),
  163.                 ),
  164.               ),
  165.               BarChartRodData(
  166.                 toY: trend.expense,
  167.                 color: colors.expense,
  168.                 width: 12,
  169.                 borderRadius: const BorderRadius.vertical(
  170.                   top: Radius.circular(4),
  171.                 ),
  172.               ),
  173.             ],
  174.             barsSpace: 4,
  175.           );
  176.         }).toList(),
  177.         
  178.         barTouchData: BarTouchData(
  179.           enabled: true,
  180.           touchTooltipData: BarTouchTooltipData(
  181.             tooltipBgColor: theme.colorScheme.surface,
  182.             tooltipBorder: BorderSide(
  183.               color: theme.colorScheme.outline,
  184.             ),
  185.             tooltipRoundedRadius: 8,
  186.             getTooltipItem: (group, groupIndex, rod, rodIndex) {
  187.               final trend = trends[groupIndex];
  188.               final isIncome = rodIndex == 0;
  189.               final amount = isIncome ? trend.income : trend.expense;
  190.               final label = isIncome ? '收入' : '支出';
  191.               
  192.               return BarTooltipItem(
  193.                 '${trend.month}月\n$label: ¥${amount.toStringAsFixed(2)}',
  194.                 TextStyle(
  195.                   color: isIncome ? colors.income : colors.expense,
  196.                   fontWeight: FontWeight.bold,
  197.                 ),
  198.               );
  199.             },
  200.           ),
  201.         ),
  202.       ),
  203.     );
  204.   }
  205.   double _calculateInterval(double maxValue) {
  206.     if (maxValue <= 1000) return 200;
  207.     if (maxValue <= 10000) return 2000;
  208.     if (maxValue <= 100000) return 20000;
  209.     return 50000;
  210.   }
  211.   String _formatAmount(double amount) {
  212.     if (amount >= 10000) {
  213.       return '${(amount / 10000).toStringAsFixed(1)}万';
  214.     }
  215.     return '${amount.toStringAsFixed(0)}';
  216.   }
  217. }
复制代码
空状态处理
  1. class ChartDataCache {
  2.   static final Map<String, CachedData> _cache = {};
  3.   static const Duration cacheExpiration = Duration(minutes: 5);
  4.   static Future<T> getOrCompute<T>(
  5.     String key,
  6.     Future<T> Function() computation,
  7.   ) async {
  8.     final cached = _cache[key];
  9.    
  10.     if (cached != null &&
  11.         DateTime.now().difference(cached.timestamp) < cacheExpiration) {
  12.       return cached.data as T;
  13.     }
  14.     final result = await computation();
  15.     _cache[key] = CachedData(
  16.       data: result,
  17.       timestamp: DateTime.now(),
  18.     );
  19.     return result;
  20.   }
  21.   static void clearCache() {
  22.     _cache.clear();
  23.   }
  24.   static void clearExpired() {
  25.     final now = DateTime.now();
  26.     _cache.removeWhere((key, value) =>
  27.         now.difference(value.timestamp) >= cacheExpiration);
  28.   }
  29. }
  30. class CachedData {
  31.   final dynamic data;
  32.   final DateTime timestamp;
  33.   CachedData({
  34.     required this.data,
  35.     required this.timestamp,
  36.   });
  37. }
复制代码
最佳实践总结

1. 数据处理原则


  • 数据分层:原始数据 -> 处理数据 -> 显示数据
  • 缓存策略:合理使用缓存避免重复计算
  • 异步加载:大数据集使用异步处理
2. 性能优化


  • 延迟渲染:复杂图表使用延迟初始化
  • 内存管理:及时清理不需要的数据
  • 动画优化:合理使用动画,避免过度渲染
3. 用户体验


  • 加载状态:提供明确的加载反馈
  • 错误处理:优雅处理数据异常
  • 交互反馈:提供触觉和视觉反馈
4. 视觉设计


  • 颜色一致性:遵循应用主题色彩
  • 可读性:确保文字和图形清晰可见
  • 响应式:适配不同屏幕尺寸
实际应用效果

在BeeCount项目中,fl_chart数据可视化系统带来了显著价值:

  • 用户洞察提升:直观的图表帮助用户理解消费模式
  • 使用时长增加:丰富的数据分析提升用户粘性
  • 专业印象:美观的图表提升应用专业形象
  • 决策支持:数据可视化辅助用户财务决策
结语

数据可视化是现代应用不可或缺的功能,fl_chart为Flutter开发者提供了强大而灵活的图表解决方案。通过合理的架构设计、性能优化和用户体验考虑,我们可以构建出既美观又实用的数据可视化系统。
BeeCount的实践证明,优秀的数据可视化不仅能提升用户体验,更能为用户创造实际价值,帮助他们更好地理解和管理自己的财务状况。
关于BeeCount项目

项目特色

<ul>
来源:程序园用户自行投稿发布,如果侵权,请联系站长删除
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!

相关推荐

您需要登录后才可以回帖 登录 | 立即注册