Files
xianyan/lib/features/mine/user_center/presentation/learning_center_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

1640 lines
52 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ============================================================
// 闲言APP — 学习中心页面 (增强版 · 双Tab合并)
// 创建时间: 2026-04-29
// 更新时间: 2026-05-21
// 作用: 个人学习中心+学习进度双Tab仪表盘+每日推荐+热力图+统计+趋势图+目标进度
// 上次更新: 统一笔记数量数据源为noteListProvider.total
// ============================================================
import 'dart:math';
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:xianyan/core/router/app_nav_extension.dart';
import 'package:shimmer/shimmer.dart';
import 'package:syncfusion_flutter_charts/charts.dart';
import '../../../../core/network/connectivity_provider.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 '../../../../core/router/app_routes.dart';
import '../../../../core/utils/ui/interaction_animations.dart';
import '../../../../shared/widgets/containers/deferred_builder.dart';
import '../../../../shared/widgets/containers/glass_container.dart';
import '../../../../shared/widgets/feedback/offline_banner.dart';
import '../../../../shared/widgets/adaptive/responsive_layout.dart';
import '../../../../shared/widgets/animation/animated_widgets.dart';
import '../providers/dashboard_provider.dart';
import '../providers/learning_progress_provider.dart';
import '../../../../features/note/providers/note_provider.dart';
import 'widgets/learning_heatmap.dart';
import 'widgets/learning_charts.dart';
import '../../../../shared/widgets/adaptive/adaptive_back_button.dart';
class LearningCenterPage extends ConsumerStatefulWidget {
const LearningCenterPage({super.key});
@override
ConsumerState<LearningCenterPage> createState() => _LearningCenterPageState();
}
class _LearningCenterPageState extends ConsumerState<LearningCenterPage> {
int _selectedYear = DateTime.now().year;
int _selectedTab = 0;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
ref.read(dashboardProvider.notifier).loadAll();
ref.read(learningProgressProvider.notifier).loadAll();
});
}
void _onYearChanged(int year) {
setState(() => _selectedYear = year);
ref.read(dashboardProvider.notifier).loadHeatmap(year: '$year');
}
@override
Widget build(BuildContext context) {
final state = ref.watch(dashboardProvider);
final progressState = ref.watch(learningProgressProvider);
final ext = AppTheme.ext(context);
final isOffline = ref.isOffline;
return CupertinoPageScaffold(
navigationBar: CupertinoNavigationBar(
leading: const AdaptiveBackButton(),
middle: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(CupertinoIcons.book_fill, size: 20, color: ext.textPrimary),
const SizedBox(width: 6),
const Text('学习'),
if (isOffline) ...[
const SizedBox(width: 6),
const OfflineIndicator(),
],
],
),
previousPageTitle: '我的',
),
child: SafeArea(
child: Column(
children: [
if (isOffline) const OfflineBanner(),
Padding(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.md,
vertical: AppSpacing.sm,
),
child: SizedBox(
width: 280,
child: CupertinoSlidingSegmentedControl<int>(
groupValue: _selectedTab,
onValueChanged: (value) {
if (value != null) setState(() => _selectedTab = value);
},
children: const {
0: Padding(
padding: EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text('📚', style: TextStyle(fontSize: 14)),
SizedBox(width: 4),
Text('学习中心'),
],
),
),
1: Padding(
padding: EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text('📊', style: TextStyle(fontSize: 14)),
SizedBox(width: 4),
Text('学习进度'),
],
),
),
},
),
),
),
Expanded(
child: _selectedTab == 0
? _buildLearningCenterTab(state, ext)
: _buildLearningProgressTab(progressState, ext),
),
],
),
),
);
}
// ============================================================
// Tab 1: 学习中心
// ============================================================
Widget _buildLearningCenterTab(DashboardState state, AppThemeExtension ext) {
final noteState = ref.watch(noteListProvider);
final noteCount = noteState.total;
return ResponsiveMaxWidth(
maxWidth: 900,
padding: AppSpacing.md,
child: CupertinoScrollbar(
child: CustomScrollView(
physics: const BouncingScrollPhysics(),
slivers: [
CupertinoSliverRefreshControl(
onRefresh: () => ref.read(dashboardProvider.notifier).loadAll(),
),
if (state.isLoading && state.dashboardData == null)
const SliverToBoxAdapter(child: _LoadingSkeleton())
else ...[
SliverToBoxAdapter(
child:
_DashboardHero(
data: state.dashboardData,
ext: ext,
noteCount: noteCount,
)
.animate()
.fadeIn(duration: 400.ms)
.slideY(
begin: 0.06,
end: 0,
duration: 400.ms,
curve: Curves.easeOutCubic,
),
),
SliverToBoxAdapter(
child:
_DailyRecommendCards(data: state.dailyRecommend, ext: ext)
.animate()
.fadeIn(duration: 400.ms, delay: 100.ms)
.slideY(
begin: 0.06,
end: 0,
duration: 400.ms,
curve: Curves.easeOutCubic,
),
),
SliverToBoxAdapter(
child:
LearningHeatmap(
data: state.heatmapData,
ext: ext,
selectedYear: _selectedYear,
onYearChanged: _onYearChanged,
)
.animate()
.fadeIn(duration: 400.ms, delay: 200.ms)
.slideY(
begin: 0.06,
end: 0,
duration: 400.ms,
curve: Curves.easeOutCubic,
),
),
SliverToBoxAdapter(
child: WeeklyTrendChart(data: state.dashboardData, ext: ext)
.animate()
.fadeIn(duration: 400.ms, delay: 300.ms)
.slideY(
begin: 0.06,
end: 0,
duration: 400.ms,
curve: Curves.easeOutCubic,
),
),
SliverToBoxAdapter(
child: LearningStatsGrid(data: state.statsData, ext: ext)
.animate()
.fadeIn(duration: 400.ms, delay: 400.ms)
.slideY(
begin: 0.06,
end: 0,
duration: 400.ms,
curve: Curves.easeOutCubic,
),
),
const SliverToBoxAdapter(child: SizedBox(height: AppSpacing.xl)),
],
],
),
),
);
}
// ============================================================
// Tab 2: 学习进度
// ============================================================
Widget _buildLearningProgressTab(
LearningProgressState state,
AppThemeExtension ext,
) {
return ResponsiveMaxWidth(
maxWidth: 900,
padding: AppSpacing.md,
child: CupertinoScrollbar(
child: CustomScrollView(
physics: const BouncingScrollPhysics(),
slivers: [
CupertinoSliverRefreshControl(
onRefresh: () =>
ref.read(learningProgressProvider.notifier).loadAll(),
),
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: _buildProgressErrorState(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 _buildProgressErrorState(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: () =>
ref.read(learningProgressProvider.notifier).loadAll(),
child: Text(
'重试',
style: AppTypography.body.copyWith(
color: ext.textOnAccent,
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),
_buildProgressStatRow(ext, state),
],
),
),
);
}
// ============================================================
// 学习进度 — 统计行
// ============================================================
Widget _buildProgressStatRow(
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: [
_buildProgressStatItem(
ext,
CupertinoIcons.eye_fill,
'$totalViews',
'浏览',
),
Container(
width: 1,
height: 30,
color: ext.textHint.withValues(alpha: 0.2),
),
_buildProgressStatItem(
ext,
CupertinoIcons.star_fill,
'$totalFavorites',
'收藏',
),
Container(
width: 1,
height: 30,
color: ext.textHint.withValues(alpha: 0.2),
),
_buildProgressStatItem(
ext,
CupertinoIcons.flame_fill,
'$streakDays',
'连续天数',
),
],
);
}
Widget _buildProgressStatItem(
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),
),
],
);
}
// ============================================================
// 学习进度 — 7天趋势折线图
// ============================================================
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;
}
// ============================================================
// 骨架屏加载
// ============================================================
class _LoadingSkeleton extends StatelessWidget {
const _LoadingSkeleton();
@override
Widget build(BuildContext context) {
final ext = AppTheme.ext(context);
return Shimmer.fromColors(
baseColor: ext.bgSecondary,
highlightColor: ext.bgCard,
child: Padding(
padding: const EdgeInsets.all(AppSpacing.md),
child: Column(
children: [
_shimmerBlock(160, AppRadius.lgBorder, ext),
const SizedBox(height: AppSpacing.md),
Row(
children: [
Expanded(child: _shimmerBlock(140, AppRadius.mdBorder, ext)),
const SizedBox(width: AppSpacing.sm),
Expanded(child: _shimmerBlock(140, AppRadius.mdBorder, ext)),
],
),
const SizedBox(height: AppSpacing.md),
_shimmerBlock(180, AppRadius.lgBorder, ext),
const SizedBox(height: AppSpacing.md),
_shimmerBlock(200, AppRadius.lgBorder, ext),
],
),
),
);
}
Widget _shimmerBlock(
double height,
BorderRadius radius,
AppThemeExtension ext,
) {
return Container(
height: height,
decoration: BoxDecoration(color: ext.bgCard, borderRadius: radius),
);
}
}
// ============================================================
// 仪表盘英雄区 — 进度环 + 头衔 + 统计
// ============================================================
class _DashboardHero extends StatelessWidget {
const _DashboardHero({
required this.data,
required this.ext,
required this.noteCount,
});
final Map<String, dynamic>? data;
final AppThemeExtension ext;
final int noteCount;
@override
Widget build(BuildContext context) {
final score = data?['score'] as int? ?? 0;
final signinDays = data?['signin_days'] as int? ?? 0;
final signinCount = data?['signin_count'] as int? ?? 0;
final favoriteCount = data?['favorite_count'] as int? ?? 0;
final likeCount = data?['like_count'] as int? ?? 0;
final commentCount = data?['comment_count'] as int? ?? 0;
final nextThreshold = _getNextTitleThreshold(score);
final progress = nextThreshold > 0
? (score / nextThreshold).clamp(0.0, 1.0)
: 0.0;
final currentTitle = _getTitleForScore(score);
return Padding(
padding: const EdgeInsets.fromLTRB(
AppSpacing.md,
AppSpacing.md,
AppSpacing.md,
AppSpacing.sm,
),
child: GlassContainer(
depth: GlassDepth.elevated,
borderRadius: AppRadius.xlBorder,
padding: const EdgeInsets.all(AppSpacing.lg),
child: Column(
children: [
Row(
children: [
_ProgressRing(progress: progress, score: score, ext: ext),
const SizedBox(width: AppSpacing.lg),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
currentTitle,
style: AppTypography.title3.copyWith(
color: ext.textPrimary,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: AppSpacing.xs),
Text(
'距下一头衔还需 ${nextThreshold - score} 积分',
style: AppTypography.footnote.copyWith(
color: ext.textSecondary,
),
),
const SizedBox(height: AppSpacing.md),
_MiniStatRow(
items: [
_MiniStatItem(
CupertinoIcons.calendar,
'$signinDays',
'天签到',
),
_MiniStatItem(
CupertinoIcons.star_fill,
'$favoriteCount',
'收藏',
),
_MiniStatItem(
CupertinoIcons.doc_text_fill,
'$noteCount',
'笔记',
),
],
ext: ext,
),
],
),
),
],
),
const SizedBox(height: AppSpacing.md),
Divider(color: ext.textHint.withValues(alpha: 0.12)),
const SizedBox(height: AppSpacing.sm),
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_StatChip(
icon: CupertinoIcons.hand_thumbsup_fill,
value: likeCount,
label: '获赞',
ext: ext,
),
_StatChip(
icon: CupertinoIcons.chat_bubble_2_fill,
value: commentCount,
label: '评论',
ext: ext,
),
_StatChip(
icon: CupertinoIcons.checkmark_circle_fill,
value: signinCount,
label: '总签到',
ext: ext,
),
],
),
],
),
),
);
}
int _getNextTitleThreshold(int score) {
const thresholds = [0, 50, 200, 500, 1500, 5000];
for (final t in thresholds) {
if (score < t) return t;
}
return 10000;
}
String _getTitleForScore(int score) {
if (score >= 5000) return '大师';
if (score >= 1500) return '专家';
if (score >= 500) return '达人';
if (score >= 200) return '熟练工';
if (score >= 50) return '学徒';
return '新手';
}
}
// ============================================================
// 进度环
// ============================================================
class _ProgressRing extends StatelessWidget {
const _ProgressRing({
required this.progress,
required this.score,
required this.ext,
});
final double progress;
final int score;
final AppThemeExtension ext;
@override
Widget build(BuildContext context) {
return SizedBox(
width: 100,
height: 100,
child: Stack(
alignment: Alignment.center,
children: [
CustomPaint(
size: const Size(100, 100),
painter: _RingPainter(
progress: progress,
color: ext.accent,
bgColor: ext.bgSecondary,
strokeWidth: 8,
),
),
Column(
mainAxisSize: MainAxisSize.min,
children: [
AnimatedCounter(
value: score,
style: AppTypography.title2.copyWith(
color: ext.accent,
fontWeight: FontWeight.w800,
),
),
Text(
'积分',
style: AppTypography.caption2.copyWith(
color: ext.textSecondary,
),
),
],
),
],
),
);
}
}
class _RingPainter extends CustomPainter {
const _RingPainter({
required this.progress,
required this.color,
required this.bgColor,
required this.strokeWidth,
});
final double progress;
final Color color;
final Color bgColor;
final double strokeWidth;
@override
void paint(Canvas canvas, Size size) {
final center = Offset(size.width / 2, size.height / 2);
final radius = (size.width - strokeWidth) / 2;
final bgPaint = Paint()
..color = bgColor
..style = PaintingStyle.stroke
..strokeWidth = strokeWidth
..strokeCap = StrokeCap.round;
canvas.drawCircle(center, radius, bgPaint);
if (progress > 0) {
final fgPaint = Paint()
..color = color
..style = PaintingStyle.stroke
..strokeWidth = strokeWidth
..strokeCap = StrokeCap.round;
canvas.drawArc(
Rect.fromCircle(center: center, radius: radius),
-pi / 2,
2 * pi * progress,
false,
fgPaint,
);
}
}
@override
bool shouldRepaint(covariant _RingPainter old) =>
old.progress != progress || old.color != color;
}
// ============================================================
// 迷你统计行 + 统计芯片
// ============================================================
class _MiniStatRow extends StatelessWidget {
const _MiniStatRow({required this.items, required this.ext});
final List<_MiniStatItem> items;
final AppThemeExtension ext;
@override
Widget build(BuildContext context) {
return Row(
children: items
.map(
(item) => Padding(
padding: const EdgeInsets.only(right: AppSpacing.md),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(item.icon, size: 14, color: ext.accent),
const SizedBox(width: 4),
Text(
item.value,
style: AppTypography.subhead.copyWith(
color: ext.textPrimary,
fontWeight: FontWeight.w600,
),
),
const SizedBox(width: 2),
Text(
item.label,
style: AppTypography.caption2.copyWith(
color: ext.textSecondary,
),
),
],
),
),
)
.toList(),
);
}
}
class _MiniStatItem {
const _MiniStatItem(this.icon, this.value, this.label);
final IconData icon;
final String value;
final String label;
}
class _StatChip extends StatelessWidget {
const _StatChip({
required this.icon,
required this.value,
required this.label,
required this.ext,
});
final IconData icon;
final int value;
final String label;
final AppThemeExtension ext;
@override
Widget build(BuildContext context) {
return Column(
children: [
Icon(icon, size: 18, color: ext.accent),
const SizedBox(height: 2),
AnimatedCounter(
value: value,
style: AppTypography.headline.copyWith(
color: ext.textPrimary,
fontWeight: FontWeight.w700,
),
),
Text(
label,
style: AppTypography.caption2.copyWith(color: ext.textSecondary),
),
],
);
}
}
// ============================================================
// 每日推荐卡片 — 2x2 网格 + 丰富内容
// ============================================================
class _DailyRecommendCards extends StatelessWidget {
const _DailyRecommendCards({required this.data, required this.ext});
final Map<String, dynamic>? data;
final AppThemeExtension ext;
@override
Widget build(BuildContext context) {
final poetry = data?['poetry'] as Map<String, dynamic>?;
final chengyu = data?['chengyu'] as Map<String, dynamic>?;
final wisdom = data?['wisdom'] as Map<String, dynamic>?;
final story = data?['story'] as Map<String, dynamic>?;
final weekday = data?['weekday'] as String? ?? '';
return Padding(
padding: const EdgeInsets.fromLTRB(
AppSpacing.md,
AppSpacing.sm,
AppSpacing.md,
AppSpacing.sm,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(
left: AppSpacing.xs,
bottom: AppSpacing.sm,
),
child: Row(
children: [
Icon(CupertinoIcons.star_fill, size: 18, color: ext.accent),
const SizedBox(width: 6),
Text(
'每日推荐',
style: AppTypography.title3.copyWith(
color: ext.textPrimary,
fontWeight: FontWeight.w700,
),
),
const SizedBox(width: AppSpacing.sm),
if (weekday.isNotEmpty)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 2,
),
decoration: BoxDecoration(
color: ext.accent.withValues(alpha: 0.12),
borderRadius: AppRadius.pillBorder,
),
child: Text(
'$weekday',
style: AppTypography.caption2.copyWith(
color: ext.accent,
fontWeight: FontWeight.w600,
),
),
),
],
),
),
GridView.count(
crossAxisCount: 2,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
mainAxisSpacing: AppSpacing.sm,
crossAxisSpacing: AppSpacing.sm,
childAspectRatio: 1.15,
children: [
_DailyCard(
icon: CupertinoIcons.doc_text_fill,
title: '今日诗词',
primary: (poetry?['title'] ?? '暂无').toString(),
secondary: (poetry?['author'] ?? '').toString(),
accentColor: const Color(0xFF6C63FF),
ext: ext,
onTap: poetry != null
? () => _navigateToDetail(
context,
'poetry',
poetry['id'] as int?,
)
: null,
),
_DailyCard(
icon: CupertinoIcons.book_fill,
title: '今日成语',
primary: (chengyu?['name'] ?? '暂无').toString(),
secondary: (chengyu?['meaning'] ?? '').toString(),
accentColor: const Color(0xFF4ECDC4),
ext: ext,
onTap: chengyu != null
? () => _navigateToDetail(
context,
'chengyu',
chengyu['id'] as int?,
)
: null,
),
_DailyCard(
icon: CupertinoIcons.chat_bubble_2_fill,
title: '今日名言',
primary: (wisdom?['author'] ?? '暂无').toString(),
secondary: (wisdom?['content'] ?? '').toString(),
accentColor: const Color(0xFFFF6B6B),
ext: ext,
onTap: wisdom != null
? () => _navigateToDetail(
context,
'wisdom',
wisdom['id'] as int?,
)
: null,
),
_DailyCard(
icon: CupertinoIcons.book_circle_fill,
title: '今日故事',
primary: (story?['title'] ?? '暂无').toString(),
secondary: '',
accentColor: const Color(0xFFFFE66D),
ext: ext,
onTap: story != null
? () => _navigateToDetail(
context,
'story',
story['id'] as int?,
)
: null,
),
],
),
],
),
);
}
void _navigateToDetail(BuildContext context, String type, int? id) {
if (id == null) return;
final nameMap = {
'poetry': '诗词',
'chengyu': '成语',
'wisdom': '名言',
'story': '故事',
};
final iconMap = {
'poetry': '📜',
'chengyu': '📖',
'wisdom': '💬',
'story': '📕',
};
context.appPush(
AppRoutes.categoryDetail.replaceAll(':type', type),
extra: {'name': nameMap[type] ?? '', 'icon': iconMap[type] ?? ''},
);
}
}
class _DailyCard extends StatelessWidget {
const _DailyCard({
required this.icon,
required this.title,
required this.primary,
required this.secondary,
required this.accentColor,
required this.ext,
this.onTap,
});
final IconData icon;
final String title;
final String primary;
final String secondary;
final Color accentColor;
final AppThemeExtension ext;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
return PressableCard(
onTap: onTap,
borderRadius: AppRadius.lgBorder,
color: ext.bgCard,
padding: EdgeInsets.zero,
child: Container(
decoration: BoxDecoration(
borderRadius: AppRadius.lgBorder,
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
accentColor.withValues(alpha: 0.08),
accentColor.withValues(alpha: 0.02),
],
),
border: Border.all(
color: accentColor.withValues(alpha: 0.15),
width: 0.5,
),
),
child: Padding(
padding: const EdgeInsets.all(AppSpacing.md),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 28,
height: 28,
decoration: BoxDecoration(
color: accentColor.withValues(alpha: 0.15),
borderRadius: AppRadius.mdBorder,
),
child: Center(
child: Icon(icon, size: 14, color: accentColor),
),
),
const SizedBox(width: AppSpacing.sm),
Expanded(
child: Text(
title,
style: AppTypography.subhead.copyWith(
fontWeight: FontWeight.w600,
color: ext.textPrimary,
),
),
),
Icon(
CupertinoIcons.chevron_right,
size: 14,
color: ext.textHint,
),
],
),
const SizedBox(height: AppSpacing.sm),
Text(
primary,
style: AppTypography.callout.copyWith(
color: ext.textPrimary,
fontWeight: FontWeight.w600,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (secondary.isNotEmpty) ...[
const SizedBox(height: 4),
Expanded(
child: Text(
secondary,
style: AppTypography.caption1.copyWith(
color: ext.textSecondary,
height: 1.4,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
],
],
),
),
),
);
}
}