凤清昶 发表于 2025-9-17 07:09:54

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

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

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

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

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

丰富的图表类型


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


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

数据模型设计

// 统计数据基类
abstract class ChartData {
final DateTime date;
final double value;
final String label;

const ChartData({
    required this.date,
    required this.value,
    required this.label,
});
}

// 日收支统计
class DailyStats extends ChartData {
final double income;
final double expense;
final double net;

const DailyStats({
    required DateTime date,
    required this.income,
    required this.expense,
    required this.net,
}) : super(
          date: date,
          value: net,
          label: '',
      );

factory DailyStats.fromTransaction(List<Transaction> transactions, DateTime date) {
    double income = 0;
    double expense = 0;

    for (final tx in transactions) {
      if (isSameDay(tx.happenedAt, date)) {
      switch (tx.type) {
          case 'income':
            income += tx.amount;
            break;
          case 'expense':
            expense += tx.amount;
            break;
      }
      }
    }

    return DailyStats(
      date: date,
      income: income,
      expense: expense,
      net: income - expense,
    );
}
}

// 分类统计
class CategoryStats extends ChartData {
final String categoryName;
final int transactionCount;
final Color color;

const CategoryStats({
    required DateTime date,
    required double value,
    required this.categoryName,
    required this.transactionCount,
    required this.color,
}) : super(
          date: date,
          value: value,
          label: categoryName,
      );
}

// 月度趋势
class MonthlyTrend extends ChartData {
final int year;
final int month;
final double income;
final double expense;

const MonthlyTrend({
    required this.year,
    required this.month,
    required this.income,
    required this.expense,
}) : super(
          date: DateTime(year, month),
          value: income - expense,
          label: '$year年$month月',
      );
}数据处理服务

class AnalyticsService {
final BeeRepository repository;

AnalyticsService(this.repository);

// 获取指定时间范围的日统计数据
Future<List<DailyStats>> getDailyStats({
    required int ledgerId,
    required DateTimeRange range,
}) async {
    final transactions = await repository.getTransactionsInRange(
      ledgerId: ledgerId,
      range: range,
    );

    final Map<DateTime, List<Transaction>> groupedByDate = {};
    for (final tx in transactions) {
      final date = DateTime(tx.happenedAt.year, tx.happenedAt.month, tx.happenedAt.day);
      groupedByDate.putIfAbsent(date, () => []).add(tx);
    }

    final List<DailyStats> result = [];
    DateTime current = DateTime(range.start.year, range.start.month, range.start.day);
    final end = DateTime(range.end.year, range.end.month, range.end.day);

    while (!current.isAfter(end)) {
      final dayTransactions = groupedByDate ?? [];
      result.add(DailyStats.fromTransaction(dayTransactions, current));
      current = current.add(const Duration(days: 1));
    }

    return result;
}

// 获取分类统计数据
Future<List<CategoryStats>> getCategoryStats({
    required int ledgerId,
    required DateTimeRange range,
    required String type, // 'income' or 'expense'
}) async {
    final transactions = await repository.getCategoryStatsInRange(
      ledgerId: ledgerId,
      range: range,
      type: type,
    );

    final Map<String, CategoryStatsData> categoryMap = {};
   
    for (final tx in transactions) {
      final categoryName = tx.categoryName ?? '未分类';
      final existing = categoryMap;
      
      if (existing == null) {
      categoryMap = CategoryStatsData(
          categoryName: categoryName,
          totalAmount: tx.amount,
          transactionCount: 1,
          color: _getCategoryColor(categoryName),
      );
      } else {
      existing.totalAmount += tx.amount;
      existing.transactionCount += 1;
      }
    }

    return categoryMap.values
      .map((data) => CategoryStats(
            date: range.start,
            value: data.totalAmount,
            categoryName: data.categoryName,
            transactionCount: data.transactionCount,
            color: data.color,
            ))
      .toList()
      ..sort((a, b) => b.value.compareTo(a.value));
}

// 获取月度趋势数据
Future<List<MonthlyTrend>> getMonthlyTrends({
    required int ledgerId,
    required int year,
}) async {
    final List<MonthlyTrend> trends = [];

    for (int month = 1; month <= 12; month++) {
      final range = DateTimeRange(
      start: DateTime(year, month, 1),
      end: DateTime(year, month + 1, 1).subtract(const Duration(days: 1)),
      );

      final monthStats = await repository.getMonthStats(
      ledgerId: ledgerId,
      range: range,
      );

      trends.add(MonthlyTrend(
      year: year,
      month: month,
      income: monthStats.income,
      expense: monthStats.expense,
      ));
    }

    return trends;
}

Color _getCategoryColor(String categoryName) {
    // 为不同分类分配固定颜色
    final colors = [
      Colors.red.shade300,
      Colors.blue.shade300,
      Colors.green.shade300,
      Colors.orange.shade300,
      Colors.purple.shade300,
      Colors.teal.shade300,
      Colors.amber.shade300,
      Colors.indigo.shade300,
    ];
   
    final index = categoryName.hashCode % colors.length;
    return colors;
}
}月度对比柱状图

响应式柱状图组件

class IncomeExpenseTrendChart extends ConsumerWidget {
final DateTimeRange dateRange;
final int ledgerId;

const IncomeExpenseTrendChart({
    Key? key,
    required this.dateRange,
    required this.ledgerId,
}) : super(key: key);

@override
Widget build(BuildContext context, WidgetRef ref) {
    final dailyStatsAsync = ref.watch(dailyStatsProvider(DailyStatsParams(
      ledgerId: ledgerId,
      range: dateRange,
    )));

    return Card(
      child: Padding(
      padding: const EdgeInsets.all(16),
      child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Row(
            mainAxisAlignment: MainAxisAlignment.spaceBetween,
            children: [
                Text(
                  '收支趋势',
                  style: Theme.of(context).textTheme.titleLarge,
                ),
                PopupMenuButton<String>(
                  onSelected: (value) {
                  // 处理时间范围选择
                  },
                  itemBuilder: (context) => [
                  const PopupMenuItem(value: '7d', child: Text('最近7天')),
                  const PopupMenuItem(value: '30d', child: Text('最近30天')),
                  const PopupMenuItem(value: '90d', child: Text('最近90天')),
                  ],
                  child: const Icon(Icons.more_vert),
                ),
            ],
            ),
            const SizedBox(height: 16),
            
            SizedBox(
            height: 280,
            child: dailyStatsAsync.when(
                data: (stats) => _buildChart(context, stats),
                loading: () => const Center(child: CircularProgressIndicator()),
                error: (error, _) => Center(
                  child: Text('加载失败: $error'),
                ),
            ),
            ),
          ],
      ),
      ),
    );
}

Widget _buildChart(BuildContext context, List<DailyStats> stats) {
    if (stats.isEmpty) {
      return const Center(
      child: Text('暂无数据'),
      );
    }

    final theme = Theme.of(context);
    final colors = BeeTheme.colorsOf(context);

    return LineChart(
      LineChartData(
      gridData: FlGridData(
          show: true,
          drawHorizontalLine: true,
          drawVerticalLine: false,
          horizontalInterval: _calculateInterval(stats),
          getDrawingHorizontalLine: (value) => FlLine(
            color: theme.colorScheme.outline.withOpacity(0.2),
            strokeWidth: 1,
          ),
      ),
      
      titlesData: FlTitlesData(
          show: true,
          rightTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)),
          topTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)),
         
          bottomTitles: AxisTitles(
            sideTitles: SideTitles(
            showTitles: true,
            reservedSize: 30,
            interval: _getBottomInterval(stats),
            getTitlesWidget: (value, meta) => _buildBottomTitle(
                context,
                stats,
                value.toInt(),
            ),
            ),
          ),
         
          leftTitles: AxisTitles(
            sideTitles: SideTitles(
            showTitles: true,
            interval: _calculateInterval(stats),
            reservedSize: 60,
            getTitlesWidget: (value, meta) => _buildLeftTitle(
                context,
                value,
            ),
            ),
          ),
      ),
      
      borderData: FlBorderData(show: false),
      
      minX: 0,
      maxX: stats.length.toDouble() - 1,
      minY: _getMinY(stats),
      maxY: _getMaxY(stats),
      
      lineBarsData: [
          // 收入线
          LineChartBarData(
            spots: _createSpots(stats, (stat) => stat.income),
            isCurved: true,
            color: colors.income,
            barWidth: 3,
            isStrokeCapRound: true,
            dotData: FlDotData(
            show: true,
            getDotPainter: (spot, percent, barData, index) =>
                  FlDotCirclePainter(
                radius: 4,
                color: colors.income,
                strokeWidth: 2,
                strokeColor: Colors.white,
            ),
            ),
            belowBarData: BarAreaData(
            show: true,
            color: colors.income.withOpacity(0.1),
            ),
          ),
         
          // 支出线
          LineChartBarData(
            spots: _createSpots(stats, (stat) => stat.expense),
            isCurved: true,
            color: colors.expense,
            barWidth: 3,
            isStrokeCapRound: true,
            dotData: FlDotData(
            show: true,
            getDotPainter: (spot, percent, barData, index) =>
                  FlDotCirclePainter(
                radius: 4,
                color: colors.expense,
                strokeWidth: 2,
                strokeColor: Colors.white,
            ),
            ),
          ),
      ],
      
      lineTouchData: LineTouchData(
          enabled: true,
          touchTooltipData: LineTouchTooltipData(
            tooltipBgColor: theme.colorScheme.surface,
            tooltipBorder: BorderSide(
            color: theme.colorScheme.outline,
            ),
            tooltipRoundedRadius: 8,
            getTooltipItems: (touchedSpots) => _buildTooltipItems(
            context,
            touchedSpots,
            stats,
            colors,
            ),
          ),
          touchCallback: (FlTouchEvent event, LineTouchResponse? touchResponse) {
            // 处理触摸事件
            if (event is FlTapUpEvent && touchResponse?.lineBarSpots != null) {
            final spot = touchResponse!.lineBarSpots!.first;
            final dayStats = stats;
            _showDayDetails(context, dayStats);
            }
          },
      ),
      ),
    );
}

List<FlSpot> _createSpots(List<DailyStats> stats, double Function(DailyStats) getValue) {
    return stats.asMap().entries.map((entry) {
      return FlSpot(entry.key.toDouble(), getValue(entry.value));
    }).toList();
}

double _calculateInterval(List<DailyStats> stats) {
    if (stats.isEmpty) return 100;
   
    final maxValue = stats
      .map((s) => math.max(s.income, s.expense))
      .reduce(math.max);
   
    if (maxValue <= 100) return 50;
    if (maxValue <= 1000) return 200;
    if (maxValue <= 10000) return 2000;
    return 5000;
}

double _getBottomInterval(List<DailyStats> stats) {
    if (stats.length <= 7) return 1;
    if (stats.length <= 14) return 2;
    if (stats.length <= 30) return 5;
    return 10;
}

Widget _buildBottomTitle(BuildContext context, List<DailyStats> stats, int index) {
    if (index < 0 || index >= stats.length) return const SizedBox.shrink();
   
    final date = stats.date;
    final text = DateFormat('MM/dd').format(date);
   
    return SideTitleWidget(
      axisSide: meta.axisSide,
      child: Text(
      text,
      style: TextStyle(
          color: Theme.of(context).colorScheme.onSurfaceVariant,
          fontSize: 12,
      ),
      ),
    );
}

Widget _buildLeftTitle(BuildContext context, double value) {
    return Text(
      _formatAmount(value),
      style: TextStyle(
      color: Theme.of(context).colorScheme.onSurfaceVariant,
      fontSize: 12,
      ),
    );
}

String _formatAmount(double amount) {
    if (amount.abs() >= 10000) {
      return '${(amount / 10000).toStringAsFixed(1)}万';
    }
    return amount.toStringAsFixed(0);
}

List<LineTooltipItem?> _buildTooltipItems(
    BuildContext context,
    List<LineBarSpot> touchedSpots,
    List<DailyStats> stats,
    BeeColors colors,
) {
    return touchedSpots.map((LineBarSpot touchedSpot) {
      const textStyle = TextStyle(
      color: Colors.white,
      fontWeight: FontWeight.bold,
      fontSize: 14,
      );
      
      final dayStats = stats;
      final date = DateFormat('MM月dd日').format(dayStats.date);
      
      if (touchedSpot.barIndex == 0) {
      // 收入线
      return LineTooltipItem(
          '$date\n收入: ${dayStats.income.toStringAsFixed(2)}',
          textStyle.copyWith(color: colors.income),
      );
      } else {
      // 支出线
      return LineTooltipItem(
          '$date\n支出: ${dayStats.expense.toStringAsFixed(2)}',
          textStyle.copyWith(color: colors.expense),
      );
      }
    }).toList();
}

void _showDayDetails(BuildContext context, DailyStats dayStats) {
    showModalBottomSheet(
      context: context,
      builder: (context) => DayDetailsSheet(dayStats: dayStats),
    );
}

double _getMinY(List<DailyStats> stats) {
    if (stats.isEmpty) return 0;
    return math.min(0, stats.map((s) => math.min(s.income, s.expense)).reduce(math.min)) * 1.1;
}

double _getMaxY(List<DailyStats> stats) {
    if (stats.isEmpty) return 100;
    return stats.map((s) => math.max(s.income, s.expense)).reduce(math.max) * 1.1;
}
}响应式数据更新

class CategoryExpensePieChart extends ConsumerStatefulWidget {
final DateTimeRange dateRange;
final int ledgerId;

const CategoryExpensePieChart({
    Key? key,
    required this.dateRange,
    required this.ledgerId,
}) : super(key: key);

@override
ConsumerState<CategoryExpensePieChart> createState() => _CategoryExpensePieChartState();
}

class _CategoryExpensePieChartState extends ConsumerState<CategoryExpensePieChart>
    with SingleTickerProviderStateMixin {
int touchedIndex = -1;
late AnimationController _animationController;
late Animation<double> _animation;

@override
void initState() {
    super.initState();
    _animationController = AnimationController(
      duration: const Duration(milliseconds: 600),
      vsync: this,
    );
    _animation = CurvedAnimation(
      parent: _animationController,
      curve: Curves.easeInOut,
    );
    _animationController.forward();
}

@override
void dispose() {
    _animationController.dispose();
    super.dispose();
}

@override
Widget build(BuildContext context) {
    final categoryStatsAsync = ref.watch(categoryStatsProvider(CategoryStatsParams(
      ledgerId: widget.ledgerId,
      range: widget.dateRange,
      type: 'expense',
    )));

    return Card(
      child: Padding(
      padding: const EdgeInsets.all(16),
      child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text(
            '支出分类',
            style: Theme.of(context).textTheme.titleLarge,
            ),
            const SizedBox(height: 16),
            
            SizedBox(
            height: 300,
            child: categoryStatsAsync.when(
                data: (stats) => _buildChart(context, stats),
                loading: () => const Center(child: CircularProgressIndicator()),
                error: (error, _) => Center(
                  child: Text('加载失败: $error'),
                ),
            ),
            ),
            
            const SizedBox(height: 16),
            categoryStatsAsync.maybeWhen(
            data: (stats) => _buildLegend(context, stats),
            orElse: () => const SizedBox.shrink(),
            ),
          ],
      ),
      ),
    );
}

Widget _buildChart(BuildContext context, List<CategoryStats> stats) {
    if (stats.isEmpty) {
      return const Center(
      child: Text('暂无支出数据'),
      );
    }

    // 只显示前8个分类,其余归为"其他"
    final displayStats = _prepareDisplayStats(stats);
    final total = displayStats.fold(0.0, (sum, stat) => sum + stat.value);

    return AnimatedBuilder(
      animation: _animation,
      builder: (context, child) {
      return PieChart(
          PieChartData(
            pieTouchData: PieTouchData(
            touchCallback: (FlTouchEvent event, pieTouchResponse) {
                setState(() {
                  if (!event.isInterestedForInteractions ||
                      pieTouchResponse == null ||
                      pieTouchResponse.touchedSection == null) {
                  touchedIndex = -1;
                  return;
                  }
                  touchedIndex = pieTouchResponse.touchedSection!.touchedSectionIndex;
                });
            },
            ),
            
            borderData: FlBorderData(show: false),
            sectionsSpace: 2,
            centerSpaceRadius: 60,
            
            sections: displayStats.asMap().entries.map((entry) {
            final index = entry.key;
            final stat = entry.value;
            final isTouched = index == touchedIndex;
            final percentage = (stat.value / total * 100);
            
            return PieChartSectionData(
                color: stat.color,
                value: stat.value,
                title: '${percentage.toStringAsFixed(1)}%',
                radius: (isTouched ? 110.0 : 100.0) * _animation.value,
                titleStyle: TextStyle(
                  fontSize: isTouched ? 16.0 : 14.0,
                  fontWeight: FontWeight.bold,
                  color: Colors.white,
                  shadows: [
                  Shadow(
                      color: Colors.black.withOpacity(0.5),
                      blurRadius: 2,
                  ),
                  ],
                ),
                badgeWidget: isTouched ? _buildBadge(stat) : null,
                badgePositionPercentageOffset: 1.2,
            );
            }).toList(),
          ),
      );
      },
    );
}

Widget _buildBadge(CategoryStats stat) {
    return Container(
      padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
      decoration: BoxDecoration(
      color: stat.color,
      borderRadius: BorderRadius.circular(12),
      border: Border.all(color: Colors.white, width: 2),
      boxShadow: [
          BoxShadow(
            color: Colors.black.withOpacity(0.2),
            blurRadius: 4,
            offset: const Offset(0, 2),
          ),
      ],
      ),
      child: Text(
      '¥${stat.value.toStringAsFixed(0)}',
      style: const TextStyle(
          color: Colors.white,
          fontWeight: FontWeight.bold,
          fontSize: 12,
      ),
      ),
    );
}

Widget _buildLegend(BuildContext context, List<CategoryStats> stats) {
    final displayStats = _prepareDisplayStats(stats);
   
    return Column(
      children: displayStats.asMap().entries.map((entry) {
      final index = entry.key;
      final stat = entry.value;
      final isHighlighted = index == touchedIndex;
      
      return AnimatedContainer(
          duration: const Duration(milliseconds: 200),
          margin: const EdgeInsets.symmetric(vertical: 2),
          padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
          decoration: BoxDecoration(
            color: isHighlighted
                ? stat.color.withOpacity(0.1)
                : Colors.transparent,
            borderRadius: BorderRadius.circular(8),
            border: isHighlighted
                ? Border.all(color: stat.color.withOpacity(0.3))
                : null,
          ),
          child: Row(
            children: [
            Container(
                width: 16,
                height: 16,
                decoration: BoxDecoration(
                  color: stat.color,
                  shape: BoxShape.circle,
                ),
            ),
            const SizedBox(width: 12),
            
            Expanded(
                child: Text(
                  stat.categoryName,
                  style: TextStyle(
                  fontWeight: isHighlighted
                        ? FontWeight.w600
                        : FontWeight.normal,
                  ),
                ),
            ),
            
            Column(
                crossAxisAlignment: CrossAxisAlignment.end,
                children: [
                  Text(
                  '¥${stat.value.toStringAsFixed(2)}',
                  style: TextStyle(
                      fontWeight: FontWeight.w600,
                      color: isHighlighted
                        ? stat.color
                        : Theme.of(context).colorScheme.onSurface,
                  ),
                  ),
                  Text(
                  '${stat.transactionCount}笔',
                  style: Theme.of(context).textTheme.bodySmall?.copyWith(
                      color: Theme.of(context).colorScheme.onSurfaceVariant,
                  ),
                  ),
                ],
            ),
            ],
          ),
      );
      }).toList(),
    );
}

List<CategoryStats> _prepareDisplayStats(List<CategoryStats> stats) {
    if (stats.length <= 8) return stats;

    final topStats = stats.take(7).toList();
    final othersValue = stats.skip(7).fold(0.0, (sum, stat) => sum + stat.value);
    final othersCount = stats.skip(7).fold(0, (sum, stat) => sum + stat.transactionCount);

    if (othersValue > 0) {
      topStats.add(CategoryStats(
      date: DateTime.now(),
      value: othersValue,
      categoryName: '其他',
      transactionCount: othersCount,
      color: Colors.grey.shade400,
      ));
    }

    return topStats;
}
}图表交互增强

手势操作支持

class MonthlyComparisonBarChart extends ConsumerWidget {
final int year;
final int ledgerId;

const MonthlyComparisonBarChart({
    Key? key,
    required this.year,
    required this.ledgerId,
}) : super(key: key);

@override
Widget build(BuildContext context, WidgetRef ref) {
    final monthlyTrendsAsync = ref.watch(monthlyTrendsProvider(MonthlyTrendsParams(
      ledgerId: ledgerId,
      year: year,
    )));

    return Card(
      child: Padding(
      padding: const EdgeInsets.all(16),
      child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Row(
            mainAxisAlignment: MainAxisAlignment.spaceBetween,
            children: [
                Text(
                  '$year年月度对比',
                  style: Theme.of(context).textTheme.titleLarge,
                ),
                Row(
                  children: [
                  _buildLegendItem(context, '收入', BeeTheme.colorsOf(context).income),
                  const SizedBox(width: 16),
                  _buildLegendItem(context, '支出', BeeTheme.colorsOf(context).expense),
                  ],
                ),
            ],
            ),
            const SizedBox(height: 16),
            
            SizedBox(
            height: 300,
            child: monthlyTrendsAsync.when(
                data: (trends) => _buildChart(context, trends),
                loading: () => const Center(child: CircularProgressIndicator()),
                error: (error, _) => Center(
                  child: Text('加载失败: $error'),
                ),
            ),
            ),
          ],
      ),
      ),
    );
}

Widget _buildLegendItem(BuildContext context, String label, Color color) {
    return Row(
      mainAxisSize: MainAxisSize.min,
      children: [
      Container(
          width: 12,
          height: 12,
          decoration: BoxDecoration(
            color: color,
            borderRadius: BorderRadius.circular(2),
          ),
      ),
      const SizedBox(width: 6),
      Text(
          label,
          style: Theme.of(context).textTheme.bodySmall,
      ),
      ],
    );
}

Widget _buildChart(BuildContext context, List<MonthlyTrend> trends) {
    if (trends.isEmpty) {
      return const Center(
      child: Text('暂无数据'),
      );
    }

    final theme = Theme.of(context);
    final colors = BeeTheme.colorsOf(context);
    final maxValue = trends
      .map((t) => math.max(t.income, t.expense))
      .reduce(math.max);

    return BarChart(
      BarChartData(
      alignment: BarChartAlignment.spaceAround,
      maxY: maxValue * 1.2,
      
      gridData: FlGridData(
          show: true,
          drawHorizontalLine: true,
          drawVerticalLine: false,
          horizontalInterval: _calculateInterval(maxValue),
          getDrawingHorizontalLine: (value) => FlLine(
            color: theme.colorScheme.outline.withOpacity(0.2),
            strokeWidth: 1,
          ),
      ),
      
      titlesData: FlTitlesData(
          show: true,
          rightTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)),
          topTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)),
         
          bottomTitles: AxisTitles(
            sideTitles: SideTitles(
            showTitles: true,
            getTitlesWidget: (value, meta) {
                final month = value.toInt() + 1;
                return SideTitleWidget(
                  axisSide: meta.axisSide,
                  child: Text(
                  '${month}月',
                  style: TextStyle(
                      color: theme.colorScheme.onSurfaceVariant,
                      fontSize: 12,
                  ),
                  ),
                );
            },
            ),
          ),
         
          leftTitles: AxisTitles(
            sideTitles: SideTitles(
            showTitles: true,
            reservedSize: 60,
            interval: _calculateInterval(maxValue),
            getTitlesWidget: (value, meta) {
                return Text(
                  _formatAmount(value),
                  style: TextStyle(
                  color: theme.colorScheme.onSurfaceVariant,
                  fontSize: 12,
                  ),
                );
            },
            ),
          ),
      ),
      
      borderData: FlBorderData(show: false),
      
      barGroups: trends.asMap().entries.map((entry) {
          final index = entry.key;
          final trend = entry.value;
         
          return BarChartGroupData(
            x: index,
            barRods: [
            BarChartRodData(
                toY: trend.income,
                color: colors.income,
                width: 12,
                borderRadius: const BorderRadius.vertical(
                  top: Radius.circular(4),
                ),
                backDrawRodData: BackgroundBarChartRodData(
                  show: true,
                  toY: maxValue * 1.2,
                  color: theme.colorScheme.surfaceVariant.withOpacity(0.3),
                ),
            ),
            BarChartRodData(
                toY: trend.expense,
                color: colors.expense,
                width: 12,
                borderRadius: const BorderRadius.vertical(
                  top: Radius.circular(4),
                ),
            ),
            ],
            barsSpace: 4,
          );
      }).toList(),
      
      barTouchData: BarTouchData(
          enabled: true,
          touchTooltipData: BarTouchTooltipData(
            tooltipBgColor: theme.colorScheme.surface,
            tooltipBorder: BorderSide(
            color: theme.colorScheme.outline,
            ),
            tooltipRoundedRadius: 8,
            getTooltipItem: (group, groupIndex, rod, rodIndex) {
            final trend = trends;
            final isIncome = rodIndex == 0;
            final amount = isIncome ? trend.income : trend.expense;
            final label = isIncome ? '收入' : '支出';
            
            return BarTooltipItem(
                '${trend.month}月\n$label: ¥${amount.toStringAsFixed(2)}',
                TextStyle(
                  color: isIncome ? colors.income : colors.expense,
                  fontWeight: FontWeight.bold,
                ),
            );
            },
          ),
      ),
      ),
    );
}

double _calculateInterval(double maxValue) {
    if (maxValue <= 1000) return 200;
    if (maxValue <= 10000) return 2000;
    if (maxValue <= 100000) return 20000;
    return 50000;
}

String _formatAmount(double amount) {
    if (amount >= 10000) {
      return '${(amount / 10000).toStringAsFixed(1)}万';
    }
    return '${amount.toStringAsFixed(0)}';
}
}空状态处理

class ChartDataCache {
static final Map<String, CachedData> _cache = {};
static const Duration cacheExpiration = Duration(minutes: 5);

static Future<T> getOrCompute<T>(
    String key,
    Future<T> Function() computation,
) async {
    final cached = _cache;
   
    if (cached != null &&
      DateTime.now().difference(cached.timestamp) < cacheExpiration) {
      return cached.data as T;
    }

    final result = await computation();
    _cache = CachedData(
      data: result,
      timestamp: DateTime.now(),
    );

    return result;
}

static void clearCache() {
    _cache.clear();
}

static void clearExpired() {
    final now = DateTime.now();
    _cache.removeWhere((key, value) =>
      now.difference(value.timestamp) >= cacheExpiration);
}
}

class CachedData {
final dynamic data;
final DateTime timestamp;

CachedData({
    required this.data,
    required this.timestamp,
});
}最佳实践总结

1. 数据处理原则


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


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


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


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

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

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

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

项目特色

<ul>
来源:程序园用户自行投稿发布,如果侵权,请联系站长删除
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!
页: [1]
查看完整版本: Flutter数据可视化:fl_chart图表库的高级应用