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