feat:增加博客统计模块
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import 'package:blog_app/models/blog.dart';
|
||||
import 'package:blog_app/pages/blog_detail_page.dart';
|
||||
import 'package:blog_app/pages/category_list_page.dart';
|
||||
import 'package:blog_app/utils/date_utils.dart';
|
||||
import 'package:blog_app/widget/common.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -138,6 +139,134 @@ class BlogCard extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
Widget buildCategoryTitle(BuildContext context, int count) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
'共 $count 个分类',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildCategoryCard(BuildContext context, BlogCategory category) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
|
||||
return buildCard(
|
||||
context: context,
|
||||
child: ListTile(
|
||||
leading: Container(
|
||||
width: 50,
|
||||
height: 50,
|
||||
decoration: BoxDecoration(
|
||||
color: colors.primary.withAlpha(50),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(Icons.folder, color: colors.primary, size: 28),
|
||||
),
|
||||
title: Text(
|
||||
category.name,
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600),
|
||||
),
|
||||
subtitle: Padding(
|
||||
padding: const EdgeInsets.only(top: 4.0),
|
||||
child: Text(
|
||||
'${category.count} 篇博客',
|
||||
style: TextStyle(fontSize: 14, color: Colors.grey.shade600),
|
||||
),
|
||||
),
|
||||
trailing: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: colors.primary.withAlpha(50),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Text(
|
||||
category.count.toString(),
|
||||
style: TextStyle(color: colors.primary, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
onTap: () => navigateToBlogList(context, category),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildListTitle(int count) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
'博客列表',
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
|
||||
),
|
||||
Text(
|
||||
'共 $count 篇',
|
||||
style: TextStyle(color: Colors.grey.shade600, fontSize: 14),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildBlogListItem(BuildContext context, Blog blog, int index) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
|
||||
return buildCard(
|
||||
context: context,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(10),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 25,
|
||||
child: Text(
|
||||
index.toString(),
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w500,
|
||||
color: colors.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// 发布时间
|
||||
SizedBox(
|
||||
width: 150,
|
||||
child: Text(
|
||||
blog.createTime,
|
||||
style: TextStyle(fontSize: 14, color: Colors.grey.shade600),
|
||||
),
|
||||
),
|
||||
|
||||
// 标题
|
||||
Expanded(
|
||||
child: Text(
|
||||
blog.title,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: colors.primary,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void navigatorToBlogDetail(BuildContext context, int blogId) {
|
||||
Navigator.push(
|
||||
context,
|
||||
@@ -145,6 +274,15 @@ void navigatorToBlogDetail(BuildContext context, int blogId) {
|
||||
);
|
||||
}
|
||||
|
||||
void navigateToBlogList(BuildContext context, BlogCategory category) {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => CategoryListPage(category: category.name),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildEmpty() {
|
||||
return Center(
|
||||
child: Column(
|
||||
|
||||
285
lib/widget/chart.dart
Normal file
285
lib/widget/chart.dart
Normal file
@@ -0,0 +1,285 @@
|
||||
import 'package:blog_app/models/common.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:syncfusion_flutter_charts/charts.dart';
|
||||
|
||||
Widget buildChartTitle(BuildContext context, String title, IconData icon) {
|
||||
return Row(
|
||||
children: [
|
||||
Icon(icon, color: Theme.of(context).colorScheme.primary),
|
||||
Text(title),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildChartDivider(BuildContext context) {
|
||||
return Divider(
|
||||
height: 1,
|
||||
thickness: 1,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
indent: 0,
|
||||
endIndent: 0,
|
||||
);
|
||||
}
|
||||
|
||||
Widget lineChart({
|
||||
required BuildContext context,
|
||||
required String xAxisName,
|
||||
required String yAxisName,
|
||||
required String unit,
|
||||
required List<ChartData> data,
|
||||
}) {
|
||||
return SfCartesianChart(
|
||||
// 图表标题
|
||||
// title: ChartTitle(text: '2023年上半年销售额(万元)'),
|
||||
|
||||
// X轴配置(类别轴)
|
||||
primaryXAxis: CategoryAxis(majorGridLines: MajorGridLines(width: 0)),
|
||||
|
||||
// Y轴配置(数值轴)
|
||||
// primaryYAxis: NumericAxis(title: AxisTitle(text: '$yAxisName($unit)')),
|
||||
|
||||
// 启用图例
|
||||
legend: Legend(isVisible: true, position: LegendPosition.top),
|
||||
|
||||
// 启用交互提示(点击数据点显示详情)
|
||||
tooltipBehavior: TooltipBehavior(
|
||||
enable: true,
|
||||
format: 'point.x: point.y $unit',
|
||||
),
|
||||
|
||||
// 折线图数据系列
|
||||
series: [
|
||||
LineSeries<ChartData, String>(
|
||||
dataSource: data,
|
||||
// X轴数据映射
|
||||
xValueMapper: (ChartData chart, _) => chart.name,
|
||||
// Y轴数据映射
|
||||
yValueMapper: (ChartData chart, _) => chart.value,
|
||||
// 线条颜色
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
// 线条宽度
|
||||
width: 3,
|
||||
|
||||
// 数据点样式
|
||||
markerSettings: const MarkerSettings(
|
||||
isVisible: true,
|
||||
color: Colors.white,
|
||||
shape: DataMarkerType.circle,
|
||||
height: 6,
|
||||
width: 6,
|
||||
),
|
||||
|
||||
// 折线名称(会显示在图例中)
|
||||
name: yAxisName,
|
||||
|
||||
// 启用数据标签(直接显示数值)
|
||||
dataLabelSettings: const DataLabelSettings(
|
||||
isVisible: true,
|
||||
color: Colors.white,
|
||||
opacity: 0,
|
||||
),
|
||||
|
||||
// 动画效果
|
||||
animationDuration: 2000, // 动画时长(毫秒)
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget barChart({
|
||||
required BuildContext context,
|
||||
required String xAxisName,
|
||||
required String yAxisName,
|
||||
required String unit,
|
||||
required List<ChartData> data,
|
||||
}) {
|
||||
return SfCartesianChart(
|
||||
// X轴配置(类别轴)
|
||||
primaryXAxis: CategoryAxis(majorGridLines: MajorGridLines(width: 0)),
|
||||
|
||||
// Y轴配置(数值轴)
|
||||
// primaryYAxis: NumericAxis(title: AxisTitle(text: '$yAxisName($unit)')),
|
||||
|
||||
// 启用交互提示
|
||||
tooltipBehavior: TooltipBehavior(
|
||||
enable: true,
|
||||
format: 'point.x: point.y $unit',
|
||||
),
|
||||
|
||||
// 柱状图数据系列
|
||||
series: [
|
||||
ColumnSeries<ChartData, String>(
|
||||
dataSource: data,
|
||||
// X轴数据映射
|
||||
xValueMapper: (ChartData chart, _) => chart.name,
|
||||
// Y轴数据映射
|
||||
yValueMapper: (ChartData chart, _) => chart.value,
|
||||
|
||||
// 名称
|
||||
name: yAxisName,
|
||||
|
||||
// 柱子颜色
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
|
||||
// 柱子宽度(0-1之间,1表示占满类别间隔)
|
||||
width: 0.6,
|
||||
|
||||
// 柱子边框
|
||||
borderWidth: 1,
|
||||
borderColor: Colors.black12,
|
||||
|
||||
// 数据标签
|
||||
dataLabelSettings: const DataLabelSettings(
|
||||
isVisible: true,
|
||||
color: Colors.white,
|
||||
opacity: 0,
|
||||
alignment: ChartAlignment.center,
|
||||
),
|
||||
|
||||
// 动画效果
|
||||
animationDuration: 2000,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget doubleBarChart({
|
||||
required BuildContext context,
|
||||
required String xAxisName,
|
||||
required String yAxisName,
|
||||
required String unit,
|
||||
required List<ChartData> data1,
|
||||
required List<ChartData> data2,
|
||||
required String series1Name,
|
||||
required String series2Name,
|
||||
}) {
|
||||
return SfCartesianChart(
|
||||
// X轴配置(类别轴)
|
||||
primaryXAxis: CategoryAxis(majorGridLines: const MajorGridLines(width: 0)),
|
||||
|
||||
// Y轴配置(数值轴)
|
||||
// primaryYAxis: NumericAxis(title: AxisTitle(text: '$yAxisName($unit)')),
|
||||
|
||||
// 图例配置
|
||||
legend: Legend(
|
||||
isVisible: true,
|
||||
position: LegendPosition.top,
|
||||
overflowMode: LegendItemOverflowMode.wrap,
|
||||
),
|
||||
|
||||
// 启用交互提示
|
||||
tooltipBehavior: TooltipBehavior(
|
||||
enable: true,
|
||||
format: 'series.name: point.y $unit',
|
||||
),
|
||||
|
||||
// 双柱状图数据系列
|
||||
series: <ColumnSeries<ChartData, String>>[
|
||||
ColumnSeries<ChartData, String>(
|
||||
dataSource: data1,
|
||||
// X轴数据映射
|
||||
xValueMapper: (ChartData chart, _) => chart.name,
|
||||
// Y轴数据映射
|
||||
yValueMapper: (ChartData chart, _) => chart.value,
|
||||
// 系列名称
|
||||
name: series1Name,
|
||||
// 柱子颜色
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
// 柱子宽度
|
||||
width: 0.3,
|
||||
// 柱子边框
|
||||
borderWidth: 1,
|
||||
borderColor: Colors.black12,
|
||||
// 数据标签
|
||||
dataLabelSettings: const DataLabelSettings(
|
||||
isVisible: true,
|
||||
color: Colors.white,
|
||||
opacity: 0,
|
||||
alignment: ChartAlignment.center,
|
||||
),
|
||||
// 动画效果
|
||||
animationDuration: 2000,
|
||||
),
|
||||
ColumnSeries<ChartData, String>(
|
||||
dataSource: data2,
|
||||
// X轴数据映射
|
||||
xValueMapper: (ChartData chart, _) => chart.name,
|
||||
// Y轴数据映射
|
||||
yValueMapper: (ChartData chart, _) => chart.value,
|
||||
// 系列名称
|
||||
name: series2Name,
|
||||
// 柱子颜色
|
||||
color: Theme.of(context).colorScheme.inversePrimary,
|
||||
// 柱子宽度
|
||||
width: 0.3,
|
||||
// 柱子边框
|
||||
borderWidth: 1,
|
||||
borderColor: Colors.black12,
|
||||
// 数据标签
|
||||
dataLabelSettings: const DataLabelSettings(
|
||||
isVisible: true,
|
||||
color: Colors.white,
|
||||
opacity: 0,
|
||||
alignment: ChartAlignment.center,
|
||||
),
|
||||
// 动画效果
|
||||
animationDuration: 2000,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget pieChart({
|
||||
required BuildContext context,
|
||||
required String unit,
|
||||
required List<ChartData> data,
|
||||
}) {
|
||||
// 计算 value 的总和
|
||||
double sumValue = data.fold(0.0, (sum, item) => sum + item.value);
|
||||
|
||||
return SfCircularChart(
|
||||
// 饼图标题
|
||||
// title: ChartTitle(text: '菜谱类别占比分布'),
|
||||
|
||||
// 启用图例
|
||||
legend: const Legend(isVisible: true, position: LegendPosition.right),
|
||||
|
||||
// 启用交互提示(点击扇区显示详情)
|
||||
tooltipBehavior: TooltipBehavior(
|
||||
enable: true,
|
||||
format: 'point.x: point.y $unit',
|
||||
),
|
||||
|
||||
// 饼图系列配置
|
||||
series: [
|
||||
PieSeries<ChartData, String>(
|
||||
dataSource: data,
|
||||
// 类别映射(饼图扇区名称)
|
||||
xValueMapper: (ChartData data, _) => data.name,
|
||||
// 数值映射(扇区大小占比)
|
||||
yValueMapper: (ChartData data, _) => data.value,
|
||||
|
||||
// 扇区半径(0-1之间,1表示充满容器)
|
||||
// radius: '50%',
|
||||
|
||||
// 启用扇区分离效果
|
||||
explode: true,
|
||||
// 指定分离的扇区索引(这里分离第一个扇区)
|
||||
explodeIndex: 0,
|
||||
// 分离距离
|
||||
explodeOffset: '5%',
|
||||
|
||||
dataLabelMapper: (ChartData data, _) {
|
||||
final percentage = (data.value / sumValue * 100).toStringAsFixed(0);
|
||||
return '$percentage%';
|
||||
},
|
||||
|
||||
// 数据标签(显示在扇区上的文本)
|
||||
dataLabelSettings: DataLabelSettings(isVisible: true),
|
||||
|
||||
// 动画效果
|
||||
animationDuration: 2000,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -16,3 +16,21 @@ Widget buildCard({required BuildContext context, required Widget child}) {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget circleIconButton({
|
||||
required IconData icon,
|
||||
required VoidCallback onPressed,
|
||||
required BuildContext context,
|
||||
}) {
|
||||
return ElevatedButton(
|
||||
onPressed: onPressed,
|
||||
style: ElevatedButton.styleFrom(
|
||||
shape: CircleBorder(),
|
||||
elevation: 0,
|
||||
backgroundColor: Theme.of(context).colorScheme.primary,
|
||||
padding: EdgeInsets.zero,
|
||||
minimumSize: const Size(0, 0),
|
||||
),
|
||||
child: Icon(icon, color: Colors.white),
|
||||
);
|
||||
}
|
||||
119
lib/widget/year_selector.dart
Normal file
119
lib/widget/year_selector.dart
Normal file
@@ -0,0 +1,119 @@
|
||||
import 'package:blog_app/widget/common.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class YearSelector extends StatefulWidget {
|
||||
final int initialYear;
|
||||
final int? minYear;
|
||||
final int? maxYear;
|
||||
final Function(int) onYearChanged;
|
||||
|
||||
const YearSelector({
|
||||
super.key,
|
||||
required this.initialYear,
|
||||
required this.onYearChanged,
|
||||
this.minYear,
|
||||
this.maxYear,
|
||||
});
|
||||
|
||||
@override
|
||||
State<YearSelector> createState() => _YearSelectorState();
|
||||
}
|
||||
|
||||
// SingleTickerProviderStateMixin 动画控制器
|
||||
class _YearSelectorState extends State<YearSelector>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late int _currentYear;
|
||||
|
||||
// 用于动画效果
|
||||
late AnimationController _animationController;
|
||||
late Animation<double> _scaleAnimation;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_currentYear = widget.initialYear;
|
||||
|
||||
// 初始化动画控制器
|
||||
_animationController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
);
|
||||
|
||||
// 缩放动画
|
||||
_scaleAnimation = Tween<double>(begin: 1.0, end: 1.1).animate(
|
||||
CurvedAnimation(parent: _animationController, curve: Curves.easeInOut),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_animationController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// 切换到上一年
|
||||
void _previousYear() {
|
||||
if (widget.minYear == null || _currentYear > widget.minYear!) {
|
||||
_animateYearChange(() {
|
||||
setState(() {
|
||||
_currentYear--;
|
||||
});
|
||||
widget.onYearChanged(_currentYear);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// 切换到下一年
|
||||
void _nextYear() {
|
||||
if (widget.maxYear == null || _currentYear < widget.maxYear!) {
|
||||
_animateYearChange(() {
|
||||
setState(() {
|
||||
_currentYear++;
|
||||
});
|
||||
widget.onYearChanged(_currentYear);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// 年份变化时的动画效果
|
||||
void _animateYearChange(VoidCallback onComplete) {
|
||||
_animationController.forward().then((_) {
|
||||
onComplete();
|
||||
_animationController.reverse();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
circleIconButton(
|
||||
context: context,
|
||||
icon: Icons.chevron_left,
|
||||
onPressed: () => _previousYear(),
|
||||
),
|
||||
// 年份显示
|
||||
AnimatedBuilder(
|
||||
animation: _scaleAnimation,
|
||||
builder: (context, child) {
|
||||
return Transform.scale(scale: _scaleAnimation.value, child: child);
|
||||
},
|
||||
child: Text(
|
||||
'$_currentYear',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).primaryColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
circleIconButton(
|
||||
context: context,
|
||||
icon: Icons.chevron_right,
|
||||
onPressed: () => _nextYear(),
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user