init
This commit is contained in:
387
client/lib/features/home/widgets/progress_chart.dart
Normal file
387
client/lib/features/home/widgets/progress_chart.dart
Normal file
@@ -0,0 +1,387 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/theme/app_text_styles.dart';
|
||||
import '../../../core/theme/app_dimensions.dart';
|
||||
|
||||
/// 进度图表组件
|
||||
class ProgressChart extends StatefulWidget {
|
||||
const ProgressChart({super.key});
|
||||
|
||||
@override
|
||||
State<ProgressChart> createState() => _ProgressChartState();
|
||||
}
|
||||
|
||||
class _ProgressChartState extends State<ProgressChart>
|
||||
with TickerProviderStateMixin {
|
||||
late AnimationController _animationController;
|
||||
late Animation<double> _animation;
|
||||
|
||||
// 模拟数据
|
||||
final List<ChartData> _weeklyData = [
|
||||
ChartData('周一', 45, AppColors.primary),
|
||||
ChartData('周二', 60, AppColors.secondary),
|
||||
ChartData('周三', 30, AppColors.tertiary),
|
||||
ChartData('周四', 80, AppColors.success),
|
||||
ChartData('周五', 55, AppColors.warning),
|
||||
ChartData('周六', 70, AppColors.info),
|
||||
ChartData('周日', 40, AppColors.error),
|
||||
];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_animationController = AnimationController(
|
||||
duration: const Duration(milliseconds: 1500),
|
||||
vsync: this,
|
||||
);
|
||||
|
||||
_animation = Tween<double>(
|
||||
begin: 0.0,
|
||||
end: 1.0,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _animationController,
|
||||
curve: Curves.easeInOutCubic,
|
||||
));
|
||||
|
||||
_animationController.forward();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_animationController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(AppDimensions.spacingMd),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(AppDimensions.radiusMd),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppColors.shadow.withOpacity(0.1),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.trending_up,
|
||||
color: AppColors.primary,
|
||||
size: 24,
|
||||
),
|
||||
const SizedBox(width: AppDimensions.spacingSm),
|
||||
Text(
|
||||
'本周学习时长',
|
||||
style: AppTextStyles.titleMedium.copyWith(
|
||||
color: AppColors.onSurface,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// 时间选择器
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppDimensions.spacingSm,
|
||||
vertical: AppDimensions.spacingXs,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(AppDimensions.radiusXs),
|
||||
),
|
||||
child: Text(
|
||||
'本周',
|
||||
style: AppTextStyles.labelMedium.copyWith(
|
||||
color: AppColors.primary,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppDimensions.spacingLg),
|
||||
|
||||
// 总计信息
|
||||
_buildSummaryInfo(),
|
||||
const SizedBox(height: AppDimensions.spacingLg),
|
||||
|
||||
// 图表
|
||||
SizedBox(
|
||||
height: 200,
|
||||
child: AnimatedBuilder(
|
||||
animation: _animation,
|
||||
builder: (context, child) {
|
||||
return CustomPaint(
|
||||
size: const Size(double.infinity, 200),
|
||||
painter: BarChartPainter(
|
||||
data: _weeklyData,
|
||||
animationValue: _animation.value,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppDimensions.spacingMd),
|
||||
|
||||
// 图例
|
||||
_buildLegend(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建总计信息
|
||||
Widget _buildSummaryInfo() {
|
||||
final totalMinutes = _weeklyData.fold<int>(0, (sum, data) => sum + data.value);
|
||||
final avgMinutes = totalMinutes / _weeklyData.length;
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildSummaryItem(
|
||||
title: '总时长',
|
||||
value: '${(totalMinutes / 60).toStringAsFixed(1)}',
|
||||
unit: '小时',
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppDimensions.spacingMd),
|
||||
Expanded(
|
||||
child: _buildSummaryItem(
|
||||
title: '日均时长',
|
||||
value: avgMinutes.toStringAsFixed(0),
|
||||
unit: '分钟',
|
||||
color: AppColors.secondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppDimensions.spacingMd),
|
||||
Expanded(
|
||||
child: _buildSummaryItem(
|
||||
title: '最长单日',
|
||||
value: _weeklyData.map((e) => e.value).reduce((a, b) => a > b ? a : b).toString(),
|
||||
unit: '分钟',
|
||||
color: AppColors.success,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建总计项
|
||||
Widget _buildSummaryItem({
|
||||
required String title,
|
||||
required String value,
|
||||
required String unit,
|
||||
required Color color,
|
||||
}) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(AppDimensions.spacingSm),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(AppDimensions.radiusXs),
|
||||
border: Border.all(
|
||||
color: color.withOpacity(0.2),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
RichText(
|
||||
text: TextSpan(
|
||||
children: [
|
||||
TextSpan(
|
||||
text: value,
|
||||
style: AppTextStyles.titleLarge.copyWith(
|
||||
color: color,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
TextSpan(
|
||||
text: unit,
|
||||
style: AppTextStyles.bodySmall.copyWith(
|
||||
color: AppColors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppDimensions.spacingXs),
|
||||
Text(
|
||||
title,
|
||||
style: AppTextStyles.bodySmall.copyWith(
|
||||
color: AppColors.onSurfaceVariant,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建图例
|
||||
Widget _buildLegend() {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
_buildLegendItem(
|
||||
color: AppColors.primary,
|
||||
label: '目标: 60分钟/天',
|
||||
),
|
||||
const SizedBox(width: AppDimensions.spacingMd),
|
||||
_buildLegendItem(
|
||||
color: AppColors.success,
|
||||
label: '已完成',
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建图例项
|
||||
Widget _buildLegendItem({
|
||||
required Color color,
|
||||
required String label,
|
||||
}) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 12,
|
||||
height: 12,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppDimensions.spacingXs),
|
||||
Text(
|
||||
label,
|
||||
style: AppTextStyles.bodySmall.copyWith(
|
||||
color: AppColors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 图表数据模型
|
||||
class ChartData {
|
||||
final String label;
|
||||
final int value;
|
||||
final Color color;
|
||||
|
||||
ChartData(this.label, this.value, this.color);
|
||||
}
|
||||
|
||||
/// 柱状图绘制器
|
||||
class BarChartPainter extends CustomPainter {
|
||||
final List<ChartData> data;
|
||||
final double animationValue;
|
||||
|
||||
BarChartPainter({
|
||||
required this.data,
|
||||
required this.animationValue,
|
||||
});
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final paint = Paint();
|
||||
final maxValue = data.map((e) => e.value).reduce((a, b) => a > b ? a : b).toDouble();
|
||||
final barWidth = (size.width - (data.length + 1) * 16) / data.length;
|
||||
final chartHeight = size.height - 40; // 留出底部标签空间
|
||||
|
||||
// 绘制网格线
|
||||
_drawGridLines(canvas, size, chartHeight);
|
||||
|
||||
// 绘制柱状图
|
||||
for (int i = 0; i < data.length; i++) {
|
||||
final item = data[i];
|
||||
final x = 16 + i * (barWidth + 16);
|
||||
final barHeight = (item.value / maxValue) * chartHeight * animationValue;
|
||||
final y = chartHeight - barHeight;
|
||||
|
||||
// 绘制柱子
|
||||
paint.color = item.color.withOpacity(0.8);
|
||||
final rect = RRect.fromRectAndRadius(
|
||||
Rect.fromLTWH(x, y, barWidth, barHeight),
|
||||
const Radius.circular(4),
|
||||
);
|
||||
canvas.drawRRect(rect, paint);
|
||||
|
||||
// 绘制数值标签
|
||||
if (animationValue > 0.8) {
|
||||
final textPainter = TextPainter(
|
||||
text: TextSpan(
|
||||
text: '${item.value}',
|
||||
style: AppTextStyles.labelSmall.copyWith(
|
||||
color: AppColors.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
textDirection: TextDirection.ltr,
|
||||
);
|
||||
textPainter.layout();
|
||||
textPainter.paint(
|
||||
canvas,
|
||||
Offset(
|
||||
x + (barWidth - textPainter.width) / 2,
|
||||
y - textPainter.height - 4,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 绘制底部标签
|
||||
final labelPainter = TextPainter(
|
||||
text: TextSpan(
|
||||
text: item.label,
|
||||
style: AppTextStyles.labelSmall.copyWith(
|
||||
color: AppColors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
textDirection: TextDirection.ltr,
|
||||
);
|
||||
labelPainter.layout();
|
||||
labelPainter.paint(
|
||||
canvas,
|
||||
Offset(
|
||||
x + (barWidth - labelPainter.width) / 2,
|
||||
chartHeight + 8,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 绘制网格线
|
||||
void _drawGridLines(Canvas canvas, Size size, double chartHeight) {
|
||||
final paint = Paint()
|
||||
..color = AppColors.outline.withOpacity(0.1)
|
||||
..strokeWidth = 1;
|
||||
|
||||
// 绘制水平网格线
|
||||
for (int i = 0; i <= 4; i++) {
|
||||
final y = chartHeight * i / 4;
|
||||
canvas.drawLine(
|
||||
Offset(0, y),
|
||||
Offset(size.width, y),
|
||||
paint,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant CustomPainter oldDelegate) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user