Files
xianyan/lib/features/mine/user_center/presentation/learning_progress_page.dart
Developer ae1df22732 feat: v6.10.3 多语言翻译补全 + 17项功能修复
- 引导页协议多语言支持(languageId传递)
- 登录页双书名号修复 + 注册页协议勾选
- 个人中心页面多语言(18个翻译键)
- 网络断开提示增加关闭/刷新按钮
- 了解我们:新增秋叶qy开发者 + ayk签名修改 + 贡献者精简 + 微风暴微信搜索
- iOS快捷按钮重复修复(删除Info.plist静态定义)
- 测试账号123456警告提示
- 扫码登录自动跳转(HTTP轮询+WebSocket双通道)
- 登录页老用户按钮改次要色
- Syncfusion图表崩溃修复(DeferredBuilder+animationDuration:0)
- macOS标题栏跟随软件夜间模式
- 平台兼容分发渠道弹窗
- 软件著作权图片+交叉水印
- 桌面小部件平台兼容说明默认收起
- iOS/macOS图标更新+名称确认为闲言
- 12个语言文件补全roleNative+7个分发渠道翻译字段
2026-06-02 04:50:32 +08:00

746 lines
24 KiB
Dart

/// ============================================================
/// 闲言APP — 学习进度可视化页面
/// 创建时间: 2026-05-10
/// 更新时间: 2026-05-10
/// 作用: 环形进度图 + 趋势折线图 + 分类柱状图 + 目标设置
/// 上次更新: 修复fl_chart API兼容性
/// ============================================================
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:syncfusion_flutter_charts/charts.dart';
import '../../../../core/theme/app_radius.dart';
import '../../../../core/theme/app_spacing.dart';
import '../../../../core/theme/app_theme.dart';
import '../../../../core/theme/app_typography.dart';
import '../../../../../shared/widgets/adaptive/adaptive_back_button.dart';
import '../../../../shared/widgets/containers/deferred_builder.dart';
import '../../../../shared/widgets/containers/glass_container.dart';
import '../providers/learning_progress_provider.dart';
class LearningProgressPage extends ConsumerStatefulWidget {
const LearningProgressPage({super.key});
@override
ConsumerState<LearningProgressPage> createState() =>
_LearningProgressPageState();
}
class _LearningProgressPageState extends ConsumerState<LearningProgressPage> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
ref.read(learningProgressProvider.notifier).loadAll();
});
}
Future<void> _refresh() async {
await ref.read(learningProgressProvider.notifier).loadAll();
}
@override
Widget build(BuildContext context) {
final ext = AppTheme.ext(context);
final state = ref.watch(learningProgressProvider);
return CupertinoPageScaffold(
backgroundColor: ext.bgPrimary,
navigationBar: CupertinoNavigationBar(
middle: Text(
'📊 学习进度',
style: AppTypography.title3.copyWith(color: ext.textPrimary),
),
backgroundColor: ext.bgPrimary.withValues(alpha: 0.85),
border: null,
leading: const AdaptiveBackButton(),
),
child: SafeArea(
bottom: false,
child: Column(
children: [
Expanded(
child: CustomScrollView(
physics: const BouncingScrollPhysics(),
slivers: [
CupertinoSliverRefreshControl(onRefresh: _refresh),
if (state.isLoading && state.overviewData == null)
SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
CupertinoActivityIndicator(color: ext.accent),
const SizedBox(height: AppSpacing.md),
Text(
'加载中...',
style: AppTypography.subhead.copyWith(
color: ext.textSecondary,
),
),
],
),
),
)
else if (state.error != null && state.overviewData == null)
SliverFillRemaining(
hasScrollBody: false,
child: _buildErrorState(ext, state.error!),
)
else ...[
SliverToBoxAdapter(
child: _buildGoalRingCard(ext, state)
.animate()
.fadeIn(duration: 300.ms)
.slideY(begin: 0.05, end: 0),
),
SliverToBoxAdapter(
child: _buildTrendCard(ext, state)
.animate()
.fadeIn(duration: 300.ms, delay: 80.ms)
.slideY(begin: 0.05, end: 0),
),
SliverToBoxAdapter(
child: _buildCategoryCard(ext, state)
.animate()
.fadeIn(duration: 300.ms, delay: 130.ms)
.slideY(begin: 0.05, end: 0),
),
SliverToBoxAdapter(
child: _buildGoalSettingCard(ext, state)
.animate()
.fadeIn(duration: 300.ms, delay: 180.ms)
.slideY(begin: 0.05, end: 0),
),
SliverToBoxAdapter(
child: SizedBox(
height: MediaQuery.of(context).viewInsets.bottom + 40,
),
),
],
],
),
),
],
),
),
);
}
Widget _buildErrorState(AppThemeExtension ext, String error) {
return Padding(
padding: const EdgeInsets.all(AppSpacing.xl),
child: GlassContainer(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
CupertinoIcons.exclamationmark_triangle,
size: 48,
color: ext.textHint,
),
const SizedBox(height: AppSpacing.md),
Text(
error,
style: AppTypography.subhead.copyWith(color: ext.textSecondary),
),
const SizedBox(height: AppSpacing.md),
CupertinoButton(
color: ext.accent,
borderRadius: AppRadius.pillBorder,
onPressed: _refresh,
child: Text(
'重试',
style: AppTypography.body.copyWith(
color: CupertinoColors.white,
fontWeight: FontWeight.w600,
),
),
),
],
),
),
);
}
Widget _buildGoalRingCard(
AppThemeExtension ext,
LearningProgressState state,
) {
final rate = state.goalCompletionRate.clamp(0.0, 1.0);
final percent = (rate * 100).toInt();
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.md,
vertical: AppSpacing.sm,
),
child: GlassContainer(
depth: GlassDepth.elevated,
child: Column(
children: [
Row(
children: [
Icon(
CupertinoIcons.chart_pie_fill,
size: 16,
color: ext.accent,
),
const SizedBox(width: AppSpacing.sm),
Text(
'今日目标',
style: AppTypography.headline.copyWith(
color: ext.textPrimary,
fontWeight: FontWeight.w600,
),
),
],
),
const SizedBox(height: AppSpacing.lg),
SizedBox(
width: 180,
height: 180,
child: Stack(
alignment: Alignment.center,
children: [
SizedBox(
width: 180,
height: 180,
child: DeferredBuilder(
builder: (context) => SfCircularChart(
margin: EdgeInsets.zero,
series: [
DoughnutSeries<_RingData, String>(
dataSource: [
_RingData('已完成', rate, ext.accent),
_RingData('剩余', 1.0 - rate, ext.accent.withValues(alpha: 0.12)),
],
xValueMapper: (d, _) => d.label,
yValueMapper: (d, _) => d.value,
pointColorMapper: (d, _) => d.color,
innerRadius: '66%',
strokeWidth: 0,
animationDuration: 0,
),
],
),
),
),
Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'$percent%',
style: AppTypography.title1.copyWith(
color: ext.textPrimary,
fontWeight: FontWeight.w700,
),
),
Text(
'${state.todayProgress}/${state.dailyGoal}',
style: AppTypography.caption1.copyWith(
color: ext.textSecondary,
),
),
],
),
],
),
),
const SizedBox(height: AppSpacing.md),
_buildStatRow(ext, state),
],
),
),
);
}
Widget _buildStatRow(AppThemeExtension ext, LearningProgressState state) {
final overview = state.overviewData;
final totalViews = overview?['total_view_count'] as int? ?? 0;
final totalFavorites = overview?['total_favorite_count'] as int? ?? 0;
final streakDays = overview?['streak_days'] as int? ?? 0;
return Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_buildStatItem(ext, CupertinoIcons.eye_fill, '$totalViews', '浏览'),
Container(
width: 1,
height: 30,
color: ext.textHint.withValues(alpha: 0.2),
),
_buildStatItem(ext, CupertinoIcons.star_fill, '$totalFavorites', '收藏'),
Container(
width: 1,
height: 30,
color: ext.textHint.withValues(alpha: 0.2),
),
_buildStatItem(ext, CupertinoIcons.flame_fill, '$streakDays', '连续天数'),
],
);
}
Widget _buildStatItem(
AppThemeExtension ext,
IconData icon,
String value,
String label,
) {
return Column(
children: [
Icon(icon, size: 16, color: ext.accent),
const SizedBox(height: 2),
Text(
value,
style: AppTypography.headline.copyWith(
color: ext.textPrimary,
fontWeight: FontWeight.w700,
),
),
Text(
label,
style: AppTypography.caption2.copyWith(color: ext.textSecondary),
),
],
);
}
Widget _buildTrendCard(AppThemeExtension ext, LearningProgressState state) {
final trendData = state.trendData;
final points = <_TrendPoint>[];
if (trendData != null && trendData['points'] is List) {
final rawPoints = trendData['points'] as List;
for (int i = 0; i < rawPoints.length; i++) {
final item = rawPoints[i];
if (item is Map<String, dynamic>) {
points.add(
_TrendPoint(
(item['value'] as num?)?.toDouble() ?? 0.0,
item['label'] as String? ?? '',
),
);
}
}
}
if (points.isEmpty) {
final now = DateTime.now();
for (int i = 6; i >= 0; i--) {
final day = now.subtract(Duration(days: i));
points.add(_TrendPoint(0, _weekDayLabel(day.weekday)));
}
}
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.md,
vertical: AppSpacing.sm,
),
child: GlassContainer(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
CupertinoIcons.graph_square_fill,
size: 16,
color: ext.accent,
),
const SizedBox(width: AppSpacing.sm),
Text(
'7天趋势',
style: AppTypography.headline.copyWith(
color: ext.textPrimary,
fontWeight: FontWeight.w600,
),
),
],
),
const SizedBox(height: AppSpacing.md),
SizedBox(
height: 180,
child: DeferredBuilder(
builder: (context) => SfCartesianChart(
plotAreaBorderWidth: 0,
margin: EdgeInsets.zero,
primaryXAxis: CategoryAxis(
majorGridLines: const MajorGridLines(width: 0),
majorTickLines: const MajorTickLines(size: 0),
axisLine: const AxisLine(width: 0),
labelStyle: AppTypography.caption2.copyWith(
color: ext.textSecondary,
),
),
primaryYAxis: NumericAxis(
majorGridLines: MajorGridLines(
width: 0.5,
color: ext.textHint.withValues(alpha: 0.1),
),
majorTickLines: const MajorTickLines(size: 0),
axisLine: const AxisLine(width: 0),
labelStyle: AppTypography.caption2.copyWith(
color: ext.textSecondary,
fontSize: 0,
),
minimum: 0,
),
tooltipBehavior: TooltipBehavior(
enable: true,
format: 'point.y',
),
series: [
AreaSeries<_TrendPoint, String>(
dataSource: points,
xValueMapper: (d, _) => d.label,
yValueMapper: (d, _) => d.value,
color: ext.accent,
borderColor: ext.accent,
borderWidth: 2.5,
animationDuration: 0,
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
ext.accent.withValues(alpha: 0.25),
ext.accent.withValues(alpha: 0.02),
],
),
markerSettings: MarkerSettings(
isVisible: true,
height: 6,
width: 6,
color: ext.accent,
borderColor: ext.bgPrimary,
borderWidth: 1.5,
),
),
],
),
),
),
],
),
),
);
}
Widget _buildCategoryCard(
AppThemeExtension ext,
LearningProgressState state,
) {
final categoryData = state.categoryData;
var categories = <_CategoryItem>[];
if (categoryData != null && categoryData['categories'] is List) {
final rawList = categoryData['categories'] as List;
for (final item in rawList) {
if (item is Map<String, dynamic>) {
categories.add(
_CategoryItem(
name: item['name'] as String? ?? '',
value: (item['value'] as num?)?.toDouble() ?? 0.0,
color: _categoryColor(categories.length, ext),
),
);
}
}
}
if (categories.isEmpty) {
categories = [
_CategoryItem(name: '诗词', value: 5, color: ext.accent),
_CategoryItem(name: '成语', value: 3, color: ext.accentLight),
const _CategoryItem(
name: '典故',
value: 2,
color: CupertinoColors.systemOrange,
),
const _CategoryItem(
name: '名言',
value: 4,
color: CupertinoColors.systemGreen,
),
];
}
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.md,
vertical: AppSpacing.sm,
),
child: GlassContainer(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
CupertinoIcons.chart_bar_fill,
size: 16,
color: ext.accent,
),
const SizedBox(width: AppSpacing.sm),
Text(
'分类统计',
style: AppTypography.headline.copyWith(
color: ext.textPrimary,
fontWeight: FontWeight.w600,
),
),
],
),
const SizedBox(height: AppSpacing.md),
SizedBox(
height: 180,
child: DeferredBuilder(
builder: (context) => SfCartesianChart(
plotAreaBorderWidth: 0,
margin: EdgeInsets.zero,
primaryXAxis: CategoryAxis(
majorGridLines: const MajorGridLines(width: 0),
majorTickLines: const MajorTickLines(size: 0),
axisLine: const AxisLine(width: 0),
labelStyle: AppTypography.caption2.copyWith(
color: ext.textSecondary,
),
),
primaryYAxis: const NumericAxis(
majorGridLines: MajorGridLines(width: 0),
majorTickLines: MajorTickLines(size: 0),
axisLine: AxisLine(width: 0),
labelStyle: TextStyle(fontSize: 0),
minimum: 0,
),
tooltipBehavior: TooltipBehavior(
enable: true,
),
series: [
ColumnSeries<_CategoryItem, String>(
dataSource: categories,
xValueMapper: (d, _) => d.name,
yValueMapper: (d, _) => d.value,
pointColorMapper: (d, _) => d.color,
width: 0.5,
animationDuration: 0,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(AppRadius.md),
),
),
],
),
),
),
],
),
),
);
}
Widget _buildGoalSettingCard(
AppThemeExtension ext,
LearningProgressState state,
) {
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.md,
vertical: AppSpacing.sm,
),
child: GlassContainer(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(CupertinoIcons.flag_fill, size: 16, color: ext.accent),
const SizedBox(width: AppSpacing.sm),
Text(
'目标设置',
style: AppTypography.headline.copyWith(
color: ext.textPrimary,
fontWeight: FontWeight.w600,
),
),
],
),
const SizedBox(height: AppSpacing.md),
Row(
children: [
Text(
'每日学习目标',
style: AppTypography.subhead.copyWith(
color: ext.textSecondary,
),
),
const Spacer(),
Container(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.sm,
vertical: AppSpacing.xs,
),
decoration: BoxDecoration(
color: ext.accent.withValues(alpha: 0.12),
borderRadius: AppRadius.pillBorder,
),
child: Text(
'${state.dailyGoal}',
style: AppTypography.subhead.copyWith(
color: ext.accent,
fontWeight: FontWeight.w600,
),
),
),
],
),
const SizedBox(height: AppSpacing.sm),
CupertinoSlider(
value: state.dailyGoal.toDouble(),
min: 5,
max: 50,
divisions: 9,
activeColor: ext.accent,
onChanged: (value) {
ref
.read(learningProgressProvider.notifier)
.setDailyGoal(value.toInt());
},
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'5篇',
style: AppTypography.caption2.copyWith(color: ext.textHint),
),
Text(
'50篇',
style: AppTypography.caption2.copyWith(color: ext.textHint),
),
],
),
const SizedBox(height: AppSpacing.md),
_buildProgressBar(ext, state),
],
),
),
);
}
Widget _buildProgressBar(AppThemeExtension ext, LearningProgressState state) {
final rate = state.goalCompletionRate.clamp(0.0, 1.0);
final isCompleted = rate >= 1.0;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
'今日进度',
style: AppTypography.subhead.copyWith(color: ext.textSecondary),
),
const Spacer(),
if (isCompleted)
Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
CupertinoIcons.checkmark_circle_fill,
size: 14,
color: CupertinoColors.systemGreen,
),
const SizedBox(width: 4),
Text(
'已完成',
style: AppTypography.caption1.copyWith(
color: CupertinoColors.systemGreen,
fontWeight: FontWeight.w600,
),
),
],
)
else
Text(
'${state.todayProgress}/${state.dailyGoal}',
style: AppTypography.caption1.copyWith(
color: ext.textSecondary,
),
),
],
),
const SizedBox(height: AppSpacing.sm),
ClipRRect(
borderRadius: AppRadius.pillBorder,
child: LinearProgressIndicator(
value: rate,
minHeight: 8,
backgroundColor: ext.accent.withValues(alpha: 0.12),
valueColor: AlwaysStoppedAnimation<Color>(
isCompleted ? CupertinoColors.systemGreen : ext.accent,
),
borderRadius: AppRadius.pillBorder,
),
),
],
);
}
String _weekDayLabel(int weekday) {
return switch (weekday) {
1 => '周一',
2 => '周二',
3 => '周三',
4 => '周四',
5 => '周五',
6 => '周六',
7 => '周日',
_ => '',
};
}
Color _categoryColor(int index, AppThemeExtension ext) {
final colors = [
ext.accent,
ext.accentLight,
CupertinoColors.systemOrange,
CupertinoColors.systemGreen,
CupertinoColors.systemBlue,
CupertinoColors.systemPurple,
CupertinoColors.systemPink,
CupertinoColors.systemTeal,
];
return colors[index % colors.length];
}
}
class _CategoryItem {
const _CategoryItem({
required this.name,
required this.value,
required this.color,
});
final String name;
final double value;
final Color color;
}
class _RingData {
const _RingData(this.label, this.value, this.color);
final String label;
final double value;
final Color color;
}
class _TrendPoint {
const _TrendPoint(this.value, this.label);
final double value;
final String label;
}