diff --git a/lib/api/stats.dart b/lib/api/stats.dart new file mode 100644 index 0000000..0859288 --- /dev/null +++ b/lib/api/stats.dart @@ -0,0 +1,31 @@ +import 'package:food_hub_app/models/stats.dart'; +import 'package:food_hub_app/utils/http_util.dart'; +import 'package:food_hub_app/utils/index.dart'; + +Future queryStatsApi() { + return HttpUtil().get( + "/food/stats", + converter: (data) => SummaryStats.fromJson(data), + ); +} + +Future> queryRecordStatsApi() { + return HttpUtil().get>( + "/food/stats/record", + converter: (data) => convertListResponse(data, ChartData.fromJson), + ); +} + +Future> queryCategoryStatsApi() { + return HttpUtil().get>( + "/food/stats/category", + converter: (data) => convertListResponse(data, ChartData.fromJson), + ); +} + +Future> queryRankStatsApi() { + return HttpUtil().get>( + "/food/stats/rank", + converter: (data) => convertListResponse(data, ChartData.fromJson), + ); +} \ No newline at end of file diff --git a/lib/layout/index.dart b/lib/layout/index.dart index 5d3fab4..0712153 100644 --- a/lib/layout/index.dart +++ b/lib/layout/index.dart @@ -14,13 +14,18 @@ class NavBar extends StatelessWidget { @override Widget build(BuildContext context) { - return BottomNavigationBar( - currentIndex: currentIndex, - iconSize: 25, - type: BottomNavigationBarType.fixed, - backgroundColor: Colors.white, - items: navItems, - onTap: onTap, + return Container( + decoration: BoxDecoration( + border: Border(top: BorderSide(color: Theme.of(context).primaryColor)), + ), + child: BottomNavigationBar( + currentIndex: currentIndex, + iconSize: 25, + type: BottomNavigationBarType.fixed, + backgroundColor: Colors.white, + items: navItems, + onTap: onTap, + ), ); } } diff --git a/lib/models/stats.dart b/lib/models/stats.dart new file mode 100644 index 0000000..2258b98 --- /dev/null +++ b/lib/models/stats.dart @@ -0,0 +1,34 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'stats.g.dart'; + +@JsonSerializable() +class SummaryStats { + final int recipeCount; + final int categoryCount; + final int workCount; + + const SummaryStats({ + required this.recipeCount, + required this.categoryCount, + required this.workCount, + }); + + factory SummaryStats.fromJson(Map json) => + _$SummaryStatsFromJson(json); + + Map toJson() => _$SummaryStatsToJson(this); +} + +@JsonSerializable() +class ChartData { + final String name; + final double value; + + const ChartData({required this.name, required this.value}); + + factory ChartData.fromJson(Map json) => + _$ChartDataFromJson(json); + + Map toJson() => _$ChartDataToJson(this); +} diff --git a/lib/models/stats.g.dart b/lib/models/stats.g.dart new file mode 100644 index 0000000..2821d26 --- /dev/null +++ b/lib/models/stats.g.dart @@ -0,0 +1,30 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'stats.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +SummaryStats _$SummaryStatsFromJson(Map json) => SummaryStats( + recipeCount: (json['recipeCount'] as num).toInt(), + categoryCount: (json['categoryCount'] as num).toInt(), + workCount: (json['workCount'] as num).toInt(), +); + +Map _$SummaryStatsToJson(SummaryStats instance) => + { + 'recipeCount': instance.recipeCount, + 'categoryCount': instance.categoryCount, + 'workCount': instance.workCount, + }; + +ChartData _$ChartDataFromJson(Map json) => ChartData( + name: json['name'] as String, + value: (json['value'] as num).toDouble(), +); + +Map _$ChartDataToJson(ChartData instance) => { + 'name': instance.name, + 'value': instance.value, +}; diff --git a/lib/views/stats.dart b/lib/views/stats.dart index 6e104bc..dee7af3 100644 --- a/lib/views/stats.dart +++ b/lib/views/stats.dart @@ -1,8 +1,8 @@ import 'package:flutter/material.dart'; +import 'package:food_hub_app/api/stats.dart'; +import 'package:food_hub_app/models/stats.dart'; +import 'package:food_hub_app/widgets/common/chart.dart'; import 'package:food_hub_app/widgets/stats/card.dart'; -import 'package:graphic/graphic.dart'; - -import 'data.dart'; class StatsPage extends StatefulWidget { const StatsPage({super.key}); @@ -12,213 +12,206 @@ class StatsPage extends StatefulWidget { } class _StatsPage extends State { - var _smooth = false; - var _stepped = false; + final double chartHeight = 400; - final List> chartData = const [ - {'x': '1月', 'y': 30}, - {'x': '2月', 'y': 45}, - {'x': '3月', 'y': 28}, - {'x': '4月', 'y': 55}, - {'x': '5月', 'y': 40}, - {'x': '6月', 'y': 65}, - ]; + SummaryStats summaryStats = SummaryStats( + recipeCount: 0, + categoryCount: 0, + workCount: 0, + ); - // @override - // Widget build(BuildContext context) { - // return SingleChildScrollView( - // child: Center( - // child: Column( - // children: [ - // Container( - // margin: const EdgeInsets.only(top: 10), - // width: 350, - // height: 300, - // child: Chart( - // data: basicData, - // variables: { - // 'genre': Variable( - // accessor: (Map map) => map['genre'] as String, - // ), - // 'sold': Variable(accessor: (Map map) => map['sold'] as num), - // }, - // marks: [ - // IntervalMark( - // label: LabelEncode( - // encoder: (tuple) => Label(tuple['sold'].toString()), - // ), - // elevation: ElevationEncode( - // value: 0, - // updaters: { - // 'tap': {true: (_) => 5}, - // }, - // ), - // color: ColorEncode( - // value: Defaults.primaryColor, - // updaters: { - // 'tap': {false: (color) => color.withAlpha(100)}, - // }, - // ), - // ), - // ], - // axes: [Defaults.horizontalAxis, Defaults.verticalAxis], - // selections: {'tap': PointSelection(dim: Dim.x)}, - // tooltip: TooltipGuide(), - // crosshair: CrosshairGuide(), - // ), - // ), - // Container( - // padding: const EdgeInsets.fromLTRB(20, 40, 20, 5), - // child: const Text( - // 'Transposed Bar Chart', - // style: TextStyle(fontSize: 20), - // ), - // ), - // Container( - // margin: const EdgeInsets.only(top: 10), - // width: 350, - // height: 300, - // child: Chart( - // data: basicData, - // variables: { - // 'genre': Variable( - // accessor: (Map map) => map['genre'] as String, - // ), - // 'sold': Variable(accessor: (Map map) => map['sold'] as num), - // }, - // transforms: [Proportion(variable: 'sold', as: 'percent')], - // marks: [ - // IntervalMark( - // position: Varset('percent') / Varset('genre'), - // label: LabelEncode( - // encoder: (tuple) => Label(tuple['sold'].toString()), - // ), - // color: ColorEncode( - // variable: 'genre', - // values: Defaults.colors10, - // ), - // modifiers: [StackModifier()], - // ), - // ], - // coord: PolarCoord(transposed: true, dimCount: 1, dimFill: 1.05), - // ), - // ), - // ], - // ), - // ), - // ); - // } + late double averageRecordCount = 0; + + List recordStats = []; + List categoryStats = []; + List rankStats = []; + + @override + void initState() { + super.initState(); + refreshStats(); + } + + Future refreshStats() async { + final result1 = await queryStatsApi(); + final result2 = await queryRecordStatsApi(); + final result3 = await queryCategoryStatsApi(); + final result4 = await queryRankStatsApi(); + + setState(() { + summaryStats = result1; + recordStats = result2; + categoryStats = result3; + rankStats = result4; + + if (recordStats.isNotEmpty) { + double sumValue = recordStats.fold(0.0, (sum, item) => sum + item.value); + averageRecordCount = sumValue / recordStats.length; + } + }); + } @override Widget build(BuildContext context) { - return Padding( - padding: EdgeInsets.all(10), - child: Column( - children: [ - GridView.count( - shrinkWrap: true, - crossAxisCount: 2, - crossAxisSpacing: 10, - mainAxisSpacing: 10, - childAspectRatio: 3, - children: const [ - StatisticCard( - icon: Icons.restaurant_menu, - color: Colors.blue, - title: '菜谱总数', - value: 19, - unit: '个', - ), - StatisticCard( - icon: Icons.grid_view_rounded, - color: Colors.green, - title: '菜谱类别', - value: 5, - unit: '种', - ), - StatisticCard( - icon: Icons.flag, - color: Colors.orange, - title: '做菜次数', - value: 25, - unit: '个', - ), - StatisticCard( - icon: Icons.show_chart, - color: Colors.red, - title: '平均次数', - value: 3.13, - unit: '次/月', - ), - ], - ), - const SizedBox(height: 10), - Expanded( - child: Card( - color: Colors.white, - elevation: 0, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), - ), - child: Padding( - padding: EdgeInsets.all(10), - child: Column( - children: [ - _buildTitleSection(), - Divider( - height: 1, - thickness: 1, - color: Theme.of(context).primaryColor, - indent: 0, - endIndent: 0, - ), - // 下半部分:图表区 - Expanded(child: _buildLineChart()), - ], + return SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(5), + child: Column( + children: [ + _buildSummaryStats(), + SizedBox( + height: chartHeight, + child: Card( + color: Colors.white, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + child: Padding( + padding: const EdgeInsets.all(10), + child: _buildRecordStats(), ), ), ), - ), - ], + SizedBox( + height: chartHeight, + child: Card( + color: Colors.white, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + child: Padding( + padding: const EdgeInsets.all(10), + child: _buildCategoryStats(), + ), + ), + ), + SizedBox( + height: chartHeight, + child: Card( + color: Colors.white, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + child: Padding( + padding: const EdgeInsets.all(10), + child: _buildRankStats(), + ), + ), + ), + ], + ), ), ); } + Widget _buildSummaryStats() { + return GridView.count( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + // 禁用网格自身滚动 + crossAxisCount: 2, + // crossAxisSpacing: 5, + // mainAxisSpacing: 5, + childAspectRatio: 2, + children: [ + StatisticCard( + icon: Icons.restaurant_menu, + color: Colors.blue, + title: '菜谱总数', + value: summaryStats.recipeCount, + unit: '个', + ), + StatisticCard( + icon: Icons.grid_view_rounded, + color: Colors.green, + title: '菜谱类别', + value: summaryStats.categoryCount, + unit: '种', + ), + StatisticCard( + icon: Icons.flag, + color: Colors.orange, + title: '做菜次数', + value: summaryStats.workCount, + unit: '个', + ), + StatisticCard( + icon: Icons.show_chart, + color: Colors.red, + title: '平均次数', + value: double.parse(averageRecordCount.toStringAsFixed(2)), + unit: '次/月', + ), + ], + ); + } + + Widget _buildRecordStats() { + return Column( + children: [ + _buildTitleSection('记录统计'), + const SizedBox(height: 3), + _buildDivider(), + const SizedBox(height: 3), + Expanded( + child: lineChart( + context: context, + xAxisName: '日期', + yAxisName: '次数', + unit: '次', + data: recordStats, + ), + ), + ], + ); + } + + Widget _buildCategoryStats() { + return Column( + children: [ + _buildTitleSection('菜谱统计'), + const SizedBox(height: 3), + _buildDivider(), + const SizedBox(height: 3), + Expanded( + child: pieChart(context: context, unit: '个', data: categoryStats), + ), + ], + ); + } + + Widget _buildRankStats() { + return Column( + children: [ + _buildTitleSection('排行榜'), + const SizedBox(height: 3), + _buildDivider(), + const SizedBox(height: 3), + Expanded(child: rankChart(rankStats)), + ], + ); + } + // 构建标题区域 - Widget _buildTitleSection() { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 12), - child: Row( - children: const [ - Icon(Icons.show_chart, size: 24), - SizedBox(width: 5), - Text('月度数据趋势统计', style: TextStyle(fontWeight: FontWeight.bold)), - ], - ), + Widget _buildTitleSection(String title) { + return Row( + children: [ + Icon(Icons.insert_chart, color: Theme.of(context).primaryColor), + Text(title), + ], ); } - Widget _buildLineChart() { - return Chart( - data: chartData, - variables: { - 'date': Variable( - accessor: (Map map) => map['x'] as String, - scale: OrdinalScale( - tickCount: 5, // x轴刻度数量 - ), - ), - 'points': Variable( - accessor: (Map map) => (map['y'] ?? double.nan) as num, - ), - }, - marks: [ - LineMark( - shape: ShapeEncode(value: BasicLineShape(smooth: false)), - size: SizeEncode(value: 1.5), - ), - ], - axes: [Defaults.horizontalAxis, Defaults.verticalAxis] + Widget _buildDivider() { + return Divider( + height: 1, + thickness: 1, + color: Theme.of(context).primaryColor, + indent: 0, + endIndent: 0, ); } } diff --git a/lib/widgets/common/chart.dart b/lib/widgets/common/chart.dart new file mode 100644 index 0000000..ea4dd2d --- /dev/null +++ b/lib/widgets/common/chart.dart @@ -0,0 +1,183 @@ +import 'package:flutter/material.dart'; +import 'package:food_hub_app/models/stats.dart'; +import 'package:syncfusion_flutter_charts/charts.dart'; + +Widget lineChart({ + required BuildContext context, + required String xAxisName, + required String yAxisName, + required String unit, + required List data, +}) { + return SfCartesianChart( + // 图表标题 + // title: ChartTitle(text: '2023年上半年销售额(万元)'), + + // X轴配置(类别轴) + primaryXAxis: CategoryAxis(majorGridLines: MajorGridLines(width: 0)), + + // Y轴配置(数值轴) + // primaryYAxis: NumericAxis(title: AxisTitle(text: '$yAxisName($unit)')), + + // 启用图例 + // legend: const Legend(isVisible: true), + + // 启用交互提示(点击数据点显示详情) + tooltipBehavior: TooltipBehavior( + enable: true, + format: 'point.x: point.y $unit', + ), + + // 折线图数据系列 + series: [ + LineSeries( + dataSource: data, + // X轴数据映射 + xValueMapper: (ChartData chart, _) => chart.name, + // Y轴数据映射 + yValueMapper: (ChartData chart, _) => chart.value, + // 线条颜色 + color: Theme.of(context).primaryColor, + // 线条宽度 + 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 data, +}) { + return SfCartesianChart( + // X轴配置(类别轴) + primaryXAxis: CategoryAxis( + majorGridLines: MajorGridLines(width: 0), + title: AxisTitle(text: xAxisName), + ), + + // Y轴配置(数值轴) + primaryYAxis: NumericAxis(title: AxisTitle(text: '$yAxisName($unit)')), + + // 启用交互提示 + tooltipBehavior: TooltipBehavior( + enable: true, + format: 'point.x: point.y $unit', + ), + + // 柱状图数据系列 + series: [ + ColumnSeries( + dataSource: data, + // X轴数据映射 + xValueMapper: (ChartData chart, _) => chart.name, + // Y轴数据映射 + yValueMapper: (ChartData chart, _) => chart.value, + + // 名称 + name: yAxisName, + + // 柱子颜色 + color: Theme.of(context).primaryColor, + + // 柱子宽度(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 pieChart({ + required BuildContext context, + required String unit, + required List 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( + 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, + ), + ], + ); +} diff --git a/lib/widgets/stats/card.dart b/lib/widgets/stats/card.dart index 914ac2c..0485d5e 100644 --- a/lib/widgets/stats/card.dart +++ b/lib/widgets/stats/card.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:food_hub_app/models/stats.dart'; class StatisticCard extends StatelessWidget { final IconData icon; @@ -25,13 +26,14 @@ class StatisticCard extends StatelessWidget { child: Padding( padding: const EdgeInsets.all(10.0), child: Row( + mainAxisAlignment: MainAxisAlignment.center, children: [ SizedBox( - width: 60, - height: 60, - child: Icon(icon, color: color, size: 42), + width: 40, + height: 40, + child: Icon(icon, color: color, size: 40), ), - const SizedBox(width: 16), + const SizedBox(width: 10), Column( crossAxisAlignment: CrossAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center, @@ -56,3 +58,58 @@ class StatisticCard extends StatelessWidget { ); } } + +// 获取排名对应的颜色 +Color _getRankColor(int index) { + switch (index) { + case 0: // 第1名 + return Colors.amber; // 金色 + case 1: // 第2名 + return Colors.grey; // 银色 + case 2: // 第3名 + return Colors.orange[700]!; // 铜色 + default: + return Colors.black; // 普通颜色 + } +} + +Widget rankChart(List items) { + return ListView.builder( + itemCount: items.length, + itemBuilder: (context, index) { + final item = items[index]; + final rank = index + 1; + return SizedBox( + height: 35, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Container( + width: 25, + alignment: Alignment.center, + child: Text( + '$rank.', + style: TextStyle( + fontWeight: FontWeight.bold, + color: _getRankColor(index), + ), + ), + ), + Text( + item.name, + style: TextStyle(color: _getRankColor(index)), + ), + ], + ), + Text( + '${item.value.toStringAsFixed(0)} 次', + style: TextStyle(color: _getRankColor(index)), + ), + ], + ), + ); + }, + ); +} diff --git a/pubspec.lock b/pubspec.lock index 32e222b..3944e5a 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -373,14 +373,6 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "2.1.3" - graphic: - dependency: "direct main" - description: - name: graphic - sha256: af3a5a967d95ce2c2c9f7dee83c21f8bd6e6a458341820920cf15c0311cdaddc - url: "https://pub.flutter-io.cn" - source: hosted - version: "2.6.0" graphs: dependency: transitive description: @@ -842,6 +834,22 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.4.1" + syncfusion_flutter_charts: + dependency: "direct main" + description: + name: syncfusion_flutter_charts + sha256: c58ca79e072680af6f0554f7c4b91886c1d8808f2522b49d557f16fb5cb3bb04 + url: "https://pub.flutter-io.cn" + source: hosted + version: "30.1.41" + syncfusion_flutter_core: + dependency: transitive + description: + name: syncfusion_flutter_core + sha256: "536753489e168f49659261d0abd02b101b34c0b1bde27898f2869aab05f1cbb6" + url: "https://pub.flutter-io.cn" + source: hosted + version: "30.1.41" table_calendar: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 9483416..f5a366d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -40,7 +40,6 @@ dependencies: table_calendar: ^3.1.3 flutter_form_builder: ^10.0.0 form_builder_validators: ^11.1.2 - graphic: ^2.6.0 intl: ^0.19.0 tdesign_flutter: ^0.2.3 json_annotation: ^4.9.0 @@ -50,6 +49,7 @@ dependencies: photo_view: ^0.15.0 flutter_carousel_widget: ^3.1.0 easy_refresh: ^3.4.0 + syncfusion_flutter_charts: ^30.1.41 dependency_overrides: tdesign_flutter_adaptation: 3.16.0