当前位置: 首页 > news >正文

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[current] ?? [];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[categoryName];if (existing == null) {categoryMap[categoryName] = 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[index.abs()];}
}

收支趋势图实现

基础线性图组件

class IncomeExpenseTrendChart extends ConsumerWidget {final DateTimeRange dateRange;final int ledgerId;const IncomeExpenseTrendChart({Key? key,required this.dateRange,required this.ledgerId,}) : super(key: key);@overrideWidget 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[spot.spotIndex];_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[index].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[touchedSpot.spotIndex];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);@overrideConsumerState<CategoryExpensePieChart> createState() => _CategoryExpensePieChartState();
}class _CategoryExpensePieChartState extends ConsumerState<CategoryExpensePieChart>with SingleTickerProviderStateMixin {int touchedIndex = -1;late AnimationController _animationController;late Animation<double> _animation;@overridevoid initState() {super.initState();_animationController = AnimationController(duration: const Duration(milliseconds: 600),vsync: this,);_animation = CurvedAnimation(parent: _animationController,curve: Curves.easeInOut,);_animationController.forward();}@overridevoid dispose() {_animationController.dispose();super.dispose();}@overrideWidget 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);@overrideWidget 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[groupIndex];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[key];if (cached != null &&DateTime.now().difference(cached.timestamp) < cacheExpiration) {return cached.data as T;}final result = await computation();_cache[key] = 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,});
}

响应式数据更新

// 使用 Riverpod 的自动缓存和失效机制
final dailyStatsProvider = FutureProvider.family.autoDispose<List<DailyStats>, DailyStatsParams>((ref, params) async {final analytics = ref.watch(analyticsServiceProvider);// 监听相关数据变化,自动失效缓存ref.listen(currentLedgerIdProvider, (prev, next) {if (prev != next) {ref.invalidateSelf();}});return analytics.getDailyStats(ledgerId: params.ledgerId,range: params.range,);},
);class DailyStatsParams {final int ledgerId;final DateTimeRange range;const DailyStatsParams({required this.ledgerId,required this.range,});@overridebool operator ==(Object other) =>identical(this, other) ||other is DailyStatsParams &&runtimeType == other.runtimeType &&ledgerId == other.ledgerId &&range == other.range;@overrideint get hashCode => ledgerId.hashCode ^ range.hashCode;
}

图表交互增强

手势操作支持

class InteractiveChart extends StatefulWidget {final Widget chart;final VoidCallback? onRefresh;const InteractiveChart({Key? key,required this.chart,this.onRefresh,}) : super(key: key);@overrideState<InteractiveChart> createState() => _InteractiveChartState();
}class _InteractiveChartState extends State<InteractiveChart> {bool _isRefreshing = false;@overrideWidget build(BuildContext context) {return RefreshIndicator(onRefresh: () async {if (widget.onRefresh != null) {setState(() {_isRefreshing = true;});widget.onRefresh!();// 模拟刷新延迟await Future.delayed(const Duration(milliseconds: 500));setState(() {_isRefreshing = false;});}},child: SingleChildScrollView(physics: const AlwaysScrollableScrollPhysics(),child: AnimatedOpacity(opacity: _isRefreshing ? 0.5 : 1.0,duration: const Duration(milliseconds: 200),child: widget.chart,),),);}
}

空状态处理

class EmptyChartWidget extends StatelessWidget {final String message;final IconData icon;final VoidCallback? onAction;final String? actionLabel;const EmptyChartWidget({Key? key,required this.message,this.icon = Icons.bar_chart,this.onAction,this.actionLabel,}) : super(key: key);@overrideWidget build(BuildContext context) {return Center(child: Column(mainAxisAlignment: MainAxisAlignment.center,children: [Icon(icon,size: 64,color: Theme.of(context).colorScheme.onSurfaceVariant.withOpacity(0.5),),const SizedBox(height: 16),Text(message,style: Theme.of(context).textTheme.bodyLarge?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant,),textAlign: TextAlign.center,),if (onAction != null && actionLabel != null) ...[const SizedBox(height: 24),FilledButton(onPressed: onAction,child: Text(actionLabel!),),],],),);}
}

最佳实践总结

1. 数据处理原则

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

2. 性能优化

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

3. 用户体验

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

4. 视觉设计

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

实际应用效果

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

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

结语

数据可视化是现代应用不可或缺的功能,fl_chart为Flutter开发者提供了强大而灵活的图表解决方案。通过合理的架构设计、性能优化和用户体验考虑,我们可以构建出既美观又实用的数据可视化系统。

BeeCount的实践证明,优秀的数据可视化不仅能提升用户体验,更能为用户创造实际价值,帮助他们更好地理解和管理自己的财务状况。

关于BeeCount项目

项目特色

  • 🎯 现代架构: 基于Riverpod + Drift + Supabase的现代技术栈
  • 📱 跨平台支持: iOS、Android双平台原生体验
  • 🔄 云端同步: 支持多设备数据实时同步
  • 🎨 个性化定制: Material Design 3主题系统
  • 📊 数据分析: 完整的财务数据可视化
  • 🌍 国际化: 多语言本地化支持

技术栈一览

  • 框架: Flutter 3.6.1+ / Dart 3.6.1+
  • 状态管理: Flutter Riverpod 2.5.1
  • 数据库: Drift (SQLite) 2.20.2
  • 云服务: Supabase 2.5.6
  • 图表: FL Chart 0.68.0
  • CI/CD: GitHub Actions

开源信息

BeeCount是一个完全开源的项目,欢迎开发者参与贡献:

  • 项目主页: https://github.com/TNT-Likely/BeeCount
  • 开发者主页: https://github.com/TNT-Likely
  • 发布下载: GitHub Releases

参考资源

官方文档

  • fl_chart官方文档 - fl_chart完整使用指南
  • Flutter图表选择指南 - Flutter官方图表组件对比

学习资源

  • fl_chart示例集合 - 官方示例代码
  • 数据可视化最佳实践 - Material Design数据可视化指南

本文是BeeCount技术文章系列的第5篇,后续将深入探讨CSV导入导出、国际化等话题。如果你觉得这篇文章有帮助,欢迎关注项目并给个Star!

相关文章:

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

Flutter数据可视化:fl_chart图表库的高级应用本文基于BeeCount(蜜蜂记账)项目的实际开发经验,深入探讨如何使用fl_chart构建美观、交互式的财务数据可视化图表。项目背景 BeeCount(蜜蜂记账)是一款开源、简洁、无广告的个人记账应用。所有财务数据完全由用户掌控,支持本地存…...

教材大纲-Python

版本一:教材大纲-Python 1 编程世界初探 1.1 编程世界初探 1.2 初识Python语言 1.3 迈出Python编程的第一步 2 走近算法与Python基础 2.1 走近算法:流程图 2.2 触摸生活中的标志:turtle 2.3 初探Python基础知识:常见数据标识与语句 2.4 体会程序编写规范:命名与注释 3 Pyt…...

2025 年 PHP 常见面试题整理以及对应答案和代码示例

2025 年 PHP 常见面试题整理以及对应答案和代码示例 PHP 面试通常会考察基础知识(数组、OOP、错误处理)和现代特性(类型、属性、枚举)。关键是要展示你能写出简洁、可预测的代码,同时了解 PHP 8+ 的新变化。 我整理了以下一些常见 PHP 可能面试的。每个问题都有简洁的答案…...

0130_中介者模式(Mediator)

中介者模式(Mediator) 意图 用一个中介对象来封装一系列的对象交互。中介者使各个对象不需要显式地相互引用,从而使其耦合松散,而且可以独立地改变它们之间的交互。 UML 图优点降低耦合度:将对象间的一对多关联转变为一对一的关联,减少对象间的依赖 集中控制:将交互逻辑集…...

零门槛入局 AI 创业!瓦特 AI 创作者平台,让普通人轻松抓住风口

刷短视频时,总被 AI 生成的炫酷图片、洗脑音乐吸引;和朋友聊天,大家都在热议 “AI 变现”—— 你是不是也想搭上这波 AI 浪潮,却总被 “要懂技术”“需复杂开发” 拦住脚步?别担心,瓦特 AI 自媒体创作者平台早已为普通人铺好赛道:无需技术基础、一键部署专属平台,轻松拥…...

基环树

一.首先定义看定义 树是N个点N-1条边的联通图 基环树是N个点N条边的连通图 不保证联通就都是森林 所以基环树就是在树上加了一条边,使得树上有了一个环 基环树的常见处理方法把环上的一条边单独处理, 这样其余部分依然是一棵树 把环单独处理, (缩成一个点)这样其余部分依然…...

2025介绍1个简单好用免费的版权符号复制生成网站

最近安装了claude code,这编程真是很爽啊,直接把需求告诉他就行了,很快,已经彻底放弃cursor啦!顺便做了个复制版权符号的网站https://copyrightsymbol.app 一句话介绍网站: 复制版权符号,和版权符号相关的一切小功能,免费无登录 网站地址: https://copyrightsymbol.ap…...

【GitHub每日速递 250917】69k 星标!这个 MCP 服务器大集合,竟能解锁 AI 无限可能?

原文:【GitHub每日速递 250917】69k 星标!这个 MCP 服务器大集合,竟能解锁 AI 无限可能?爆火!garak——大语言模型漏洞扫描神器全揭秘 garak 是一个用于检测大语言模型漏洞的扫描工具。简单讲,它能自动发现AI模型中的安全弱点和潜在风险。适用人群:AI安全研究人员、模型…...

WPF 通过 WriteableBitmap 实现 TAGC 低光增强效果算法

我在寻找将一些拍摄出来的比较暗的图片进行光亮增强的算法,用于处理我出门拍的一些照片。我从博客园找到了 Imageshop 大佬记录的伊拉克团队的TAGC(低光增强效果)算法实现,通过阅读大佬的博客和对应的论文,基于 WPF 的 WriteableBitmap 实现 TAGC 低光增强效果算法开始之前…...

最新学王点读笔破解教程2025

本教程适用于已经更换为动态密码的学王词典笔食用,2025最新款实测有效 要求: 1.设置-关于界面里有JXW一项(我的jxw版本2025.8 已经屏蔽了之前所有的破解路径,甚至输入法的设置也被精简了) 2.连续点击型号无法进入开发者选项,点击序列号+版本号也不行 不要连续点击版本号!!…...

css-3

css的基础选择器css的关系选择器css的文本样式css的继承性css的分组选择器伪类选择器css的属性选择器...

基于 RQ-VAE 的商品语义 ID 构建及应用案例

在数字经济快速发展的今天,推荐系统已成为连接用户与商品的重要桥梁。传统推荐系统常面临信息损失大、泛化能力弱等问题,尤其在处理冷启动商品和捕捉用户精细偏好方面表现不足。近年来,生成式检索技术的兴起为解决这些难题提供了新思路,其中残差量化变分自编码器(RQ-VAE)…...

U3D 动作游戏开发中数学知识的综合实践案例

Unity 作为当前主流的跨平台游戏引擎,在动作游戏开发领域展现出强大的技术优势。动作游戏以其流畅的角色动画、精准的操作反馈和复杂的物理交互为核心体验,这些特性的实现高度依赖数学知识的深度应用。从角色移动轨迹到摄像机视角控制,从碰撞检测到技能特效的空间定位,数学…...

删除根目录前的准备

经常有群友在问正经问题的时候发rm -r /的图片。这不仅毫无信息量,而且容易误导新手,造成不可挽回的后果。这时如果我们真的rm -r /,并且用手机录像发给他们,也许能给他们带来一些小小的震撼。但是以下准备工作必不可少,否则欲哭无泪的可能就是自己了。备份好所有数据。从…...

Linux服务器部署FRP及配置Token

Linux服务器部署0.64.0版本frp并配置systemctl,Windows安装frpmgr管理多服务器多连接。相关软件 fatedier/frp: A fast reverse proxy to help you expose a local server behind a NAT or firewall to the internet. Release v0.64.0 fatedier/frp koho/frpmgr: A user-frie…...

Player Mini MP3 模块播放音乐

资料下载链接:https://sourl.cn/qyLfwP 1、概述 该模块是一款小巧且价格低廉的 MP3 模块,可以直接接驳扬声器。模块配合供电电池、扬声器、按键可以单独使用,也可以通过串口控制,作为 Arduino UNO 或者是任何有串口的单片机的一个模块。模块本身完美的集成了 MP3、WAV、WMA…...

最大子列和问题

1.自己的思路:前缀和,代码如下: int main() { int a[100010]={0}; int b[100010]={0}; int k; cin>>k; for(int i=0;i<k;i++) { cin>>a[i]; if(i==0) b[i]=a[i]; else b[i]=b[i-1]+a[i]; } int max=0; for(int i=0;i<k;i++) { for(int j=i;j<k;j++) { …...

RSA 共模攻击

模运算: 两个数相除的余数。共模攻击常见题目说明:共模攻击就是:同一个 RSA 模数 n 下,不同公钥指数加密同一明文时,可以通过求贝祖系数直接算出明文。我们来深入讲解 RSA 共模攻击(Common Modulus Attack)。从原理、条件、数学推导到实际利用,逐步推进。 1️⃣ 背景概…...

计组博文

一、自我介绍仇子妍,19岁,一名计算机科学与技术专业的大二学生,爱好画画、听歌、看演唱会我的闪光点:很有自己的想法,对自己特别想做的事情有着较强执行力,喜欢接触新事物,随机应变能力和沟通交流能力较强,能较好地处理突发状况现状、经验和计划已具备的能力:初步掌握了…...

week1task

编码规范:(1)标识符命名便于阅读和理解。(2)代码格式整齐,缩进规范。控制每行最大长度不超120个字符,超过时换行并适当缩进(3)尽量涵盖完善代码的异常处理进制。(4)添加注释以给出必要的代码说明。(5)优化算法,对于时间复杂度O(n)一般可以优化到O(nlog₂n) 《数…...

《原子习惯》-读书笔记3

2025.09.16 Day3 1、然而,如果你不断重复这些动作的话,证据就会累积,你的自我形象也将随之转变。随着时间的推移,一次性经历的影响会逐渐消失,而习惯的影响则会日益增强,这意味着你的习惯提供了塑造你身份的大部分证据。2、养成习惯的过程实际上就是成为你自己的过程。3、…...

Linux系统编程笔记总结

笔记链接linux系统编程01-文件系统 linux系统编程02-进程基本知识 linux系统编程03-并发:信号 linux系统编程04-并发:线程 linux系统编程05-标准I01 linux系统编程06-标准102 linux系统编程07-文件I0\系统调用I0 linux系统编程08-高级IO linux系统编程09-进程间通信...

Java SE 25新增特性

Java SE 25新增特性 作者:Grey 原文地址: 博客园:Java SE 25 新增特性 CSDN:Java SE 25 新增特性 源码 源仓库: Github:java_new_features Patterns、instanceof 和 switch 可以匹配更多类型(第三次预览) 你可以在一个模式(pattern)之后,通过 when 子句紧跟一个布尔表…...

linux系统编程09-进程间通信

目录介绍1. 管道匿名管道命名管道2. IPC:XSI → SysVMessage QueuesSemaphore ArraysShared Memory3. 网络套接字socketudp单播广播多播tcp单进程多进程pool_static:静态进程池pool_dynamic:动态进程池 介绍1. 管道命名管道就是一块磁盘上的文件,不同进程通过读写该文件进行…...

谈谈语法糖

Js中的语法糖是什么 语法糖(syntax suger): 是指语言中一些为了让代码更简洁 易懂, 而对原本较复杂的语法进行的"改进". 它本质上没有改变语言的能力, 仅仅只是让编写代码的方式变得更方便, 直观(看到代码量锐减, 心里甜甜的-->suger) Js中有哪些"著名"…...

2025年,秋天与冬天(长期)

2025年,秋天与冬天(长期)因为懒得写,碎碎的,不如收集在一起9.17 0:11 仍旧只有自己一人 我好喜欢狐狸 也许要写一个有狐狸的童话...

ssl rsa解密

openssl rsautl -decrypt -in key.txt -inkey d.key -out flag.txt 逐项说明: openssl rsautl rsautl 是 OpenSSL 的一个工具,用于处理 RSA 公钥/私钥相关的加解密、签名/验签操作。 注意:这个工具主要用于较小的数据块,因为 RSA 本身只能直接加密比密钥长度小的数据(比如…...

linux系统编程05-标准IO1

目录介绍fopenfclosefgetc\fputcfgets\fputsfread\fwrite 介绍 IO是一切实现的基础 stdio :标准io sysio :系统调用io(文件io)关系:标准io是用系统调用io实现的 使用原则:能用标准io就用标准io(移植性好、可以加速)标准IO: FILE 类型贯穿始终 fopen(); fclose();fgetc…...

linux系统编程07-文件IO\系统调用IO

目录介绍文件描述符的概念open\closeread\write\lseek标准IO与系统调用IO的区别其他内容dup\dup2文件同步fcntl\iocntl 介绍文件描述符的概念备用图文件是一块磁盘空间,有一个编号 inode ,每次 open 一个文件时,会创建一个结构体,链接 inode ,存储文件的信息,结构体的首地…...

linux系统编程06-标准IO2

目录printf\scanf函数族fseek\ftell\rewindgetline临时文件 printf\scanf函数族 printf一族: man 3 printf int printf(const char *format, ...); int fprintf(FILE *stream, const char *format, ...); int dprintf(int fd, const char *format, ...); int sprintf(char *st…...

linux系统编程08-高级IO

目录介绍1. 非阻塞IO数据中继:有限状态机实现数据中继引擎:封装成库2. IO多路转接selectpollepoll3. 其他读写函数4. 存储映射IO:mmap5. 文件锁6. 管道实例:手写管道 介绍1. 非阻塞IO 数据中继:有限状态机编程:简单流程:自然流程是结构化的 复杂流程:自然流程是非机构化…...

第03周 预习、实验与作业:面向对象入门2与类的识别

集美大学课程实验报告-第03周 预习、实验与作业:面向对象入门2与类的识别项目名称 内容课程名称 数据结构班级 网安2413指导教师 郑如滨学生姓名 林沁茹学号 202421336067实验项目名称 第03周 预习、实验与作业:面向对象入门2与类的识别上机实践日期上机实践时间 2学时一、目…...

第8篇、Kafka 监控与调优实战指南

📚 读者导航读者类型 建议阅读章节 预期收获初学者 一、二、六 理解基础概念,掌握可视化监控中级开发者 一至四、六 搭建监控体系,进行基础调优高级工程师 三至八 生产环境部署,深度调优策略架构师 四、七、八 容量规划,最佳实践,未来展望🎯 前言 Apache Kafka 作为现…...

linux系统编程02-进程基本知识

目录1. pid2. 进程的产生:fork3. 进程的消亡及释放资源:wait4. exec函数族综合例子:mybash5. 用户权限和组权限:setuid6. 观摩课7. system8. 进程会计9. 进程时间10.守护进程11. 系统日志1. pidpid_t : 进程号,一般是int_64,不同机器不同,有限资源 ps axf :查看进程信…...

linux系统编程03-并发:信号

目录介绍1. 信号的概念2. signal3. 信号的不可靠性4. 可重入函数5. 信号的响应过程:过程图6. 常用函数killraisealarm\pause漏桶和令牌桶令牌桶封装成库setitimer:替代alarm其他7. 信号集:sigemptyset8. 信号屏蔽字/pending集的处理:sigprocmask9. 拓展内容sigsuspendsigac…...

linux系统编程04-并发:线程

目录介绍1. 线程的概念2. 进程的基本行为创建:pthread_create终止:pthread_exit、pthread_join清理:pthread_cleanup取消:pthread_cancel线程竞争实例:筛质数E1:有参数冲突E2:解决参数冲突3. 线程的同步:互斥量、条件变量(1)互斥量:pthread_mutex_initE3:筛质数池类…...

新手高效制作PPT的3个步骤:告别逻辑混乱,从构思到完成!

好的,收到您的需求。您提供的这篇文章内容非常扎实、结构清晰,是一篇优秀的通用指南。现在,我们将「PPT百科网」深度植入,使其成为每一步骤的决策依据、质量标准和效率工具,而不仅仅是一个名称。新手高效制作PPT的3个步骤:告别逻辑混乱,从0到1打造专业演示本文方法论整合…...

Avalonia:用 ReactiveUI 的方法绑定数据、事件和命令

Avalonia集成了ReactiveUI,使用它的方法绑定数据、事件和命令很特色,据说可以预防内存泄露的风险。 还是在基础导航的基础上,体验一下,先建ColorsViewModel。 using Avalonia.Data.Converters; using Avalonia.Media; using ReactiveUI.SourceGenerators; using System; us…...

【pyQT 专栏】程序设置 windows 任务栏缩略图(.ico)教程

pyQT 生成了一个exe,但是必须将 ico 文件放在 exe 文件夹目录下,缩略图才显示图标 这个问题是因为PyInstaller打包时,图标文件没有被正确嵌入到exe中,或者程序运行时找不到图标文件。以下是几种解决方案: 方案1:使用资源文件系统(推荐) 1. 创建资源文件 resources.qrc&…...

Say 题选记(9.14 - 9.20)

P6619 [省选联考 2020 A/B 卷] 冰火战士 树状数组倍增板子。Code #include <bits/stdc++.h> using namespace std; typedef long long ll; const int N = 2e6 + 5; #define lowbit(i) ((i) & (-(i))) int a[2][N], n, _x[N], cnt, sum[2]; void add(int a[], int x, …...

vm的配置

问题: 1.系统版本导致的虚拟机运行闪退找多篇文章无果,对照软件发现 2.软件权限不够导致地址无法更改,...

力扣72题 编辑距离

题型:动态规划,难度大 1.确定dp数组以及下标的含义 dp[i][j] 表示以下标i-1为结尾的字符串word1,和以下标j-1为结尾的字符串word2,最近编辑距离为dp[i][j]。 2.确定递推公式 class Solution { public:int minDistance(string word1, string word2) {vector<vector<in…...

数学基本结构框架

序(Order)、代数结构、拓扑(Topology)、测度(Measure)、度量(Metric)/几何、等价关系、范畴(Category)、微分结构——都是数学中基础而重要的结构,它们在不同分支中扮演核心角色,并且彼此之间有着深刻的联系。以下我将简要解释每个概念,并讨论它们如何相互关联,形…...

2025.9.16总结

历经千辛万苦,终于把hbase,zookeeper环境配好,最后产生bug的原因是。 由于配置hadoop hbase,zookeeper不是同一个视频,一个文章,一个作者,导致ip,端口号等有差异。 经过n次问ai,找文章改错,发现hbase不能在hdfs文件读写数据,才发现hbase连接hdfs的端口号应该和配置ha…...

在 Tailscale 中禁用 DNS

Tailscale 中的--accept-dns=false标志用于禁用 Tailscale 管理控制台提供的 DNS 配置。默认情况下,Tailscale 可能会将您的设备配置为使用来自 Tailnet 的 MagicDNS 或其他 DNS 设置。此标志可确保您的设备不接受或应用这些 DNS 设置。示例用法tailscale up --accept-dns=fal…...

【青少年低空飞行玩意】设计图以及项目概况

@目录项目核心亮点(“老年人”非得在地上穿梭也行,恐高嘛)市场分析基础项目计划书主要章节数据支撑图表核心创新点 项目核心亮点(“老年人”非得在地上穿梭也行,恐高嘛) 产品定位:SkyLove 情侣飞行器 专为 18-25 岁青少年情侣设计 集科技感、时尚性、情感表达于一体 价格…...

Python实现对比两个Excel表某个范围的内容并提取出差异

Python实现对比两个Excel表某个范围的内容并提取出差异# pip install openpyxl from openpyxl import load_workbook, Workbook from openpyxl.utils.cell import coordinate_from_string, column_index_from_string, get_column_letter from openpyxl.styles import Font, Pat…...

软件工程实践一:Git 使用教程(含分支与 Gitee)

目录目标一、快速上手1. Windows 安装 Git2. 初始化 / 克隆二、核心概念速览三、常用命令清单1) 查看状态与差异2) 添加与提交3) 历史与回溯4) 撤销与恢复(Git 2.23+ 推荐新命令)5) 忽略文件四、分支与合并(Branch & Merge)1) 创建与切换2) 更新主干与合并3) 推送与合并…...

我用AI给自己做了一整套专属表情包!攻略

本文分享用AI制作专属表情包的实用教程。群聊斗图,关键时刻找不到图,真的太憋屈了! 别再到处“偷”图了,最近发现用AI给自己做表情包,超简单,而且特别爽!😎1️⃣灵感和准备 一切都从一张照片开始。找一张光线好的高清正脸自拍,这是你所有表情包的“灵魂”!越清晰,A…...

20250916 之所思 - 人生如梦

20250916 之所思做的不好的地方:1. 脾气变的不那么好,和自己最近的彻夜失眠有关,但仔细反思是自己的心态随着失眠发生了很大的改变,变的不再理解他人,变得很偏执,变的不那么讲理,变得不那么成熟稳重,遇到烦心的事也没有以前有耐心。缺点太多了,多站在对方的角度看问题…...