Phase 0 — 共享组件: - EmptyStateWidget: 统一空状态 (icon + title + subtitle + CTA) - ErrorStateWidget: 统一错误状态 (message + retry) - SkeletonBox + SkeletonList: 统一骨架屏加载 (shimmer 动画) Phase 1 — Bug 修复: - 班级评论按 journalId 过滤,避免显示在错误日记卡片下 - moodCellColors key 修正: love/tired → angry/thinking - 日历非 CalendarLoaded 状态改为加载指示器 (不再 SizedBox.shrink) - 贴纸数统计改为 '--' 占位 (之前错误显示日记总数)
504 lines
16 KiB
Dart
504 lines
16 KiB
Dart
// 个人中心页面 — 用户信息 + 功能入口 + 设置
|
||
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||
import 'package:go_router/go_router.dart';
|
||
import 'package:nuanji_app/core/theme/app_colors.dart';
|
||
import 'package:nuanji_app/core/theme/app_radius.dart';
|
||
import 'package:nuanji_app/core/constants/design_tokens.dart';
|
||
import 'package:nuanji_app/features/auth/bloc/auth_bloc.dart';
|
||
import 'package:nuanji_app/features/profile/bloc/settings_bloc.dart';
|
||
import 'package:nuanji_app/data/models/user.dart';
|
||
import 'package:nuanji_app/data/repositories/journal_repository.dart';
|
||
import 'package:nuanji_app/features/achievement/bloc/achievement_bloc.dart';
|
||
import 'package:nuanji_app/data/remote/api_client.dart';
|
||
|
||
/// 个人中心页面
|
||
class ProfilePage extends StatelessWidget {
|
||
const ProfilePage({super.key});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
final colorScheme = theme.colorScheme;
|
||
final authState = context.watch<AuthBloc>().state;
|
||
final user = authState is Authenticated ? authState.user : null;
|
||
final displayName = user?.displayLabel ?? '用户';
|
||
final role = user?.primaryRoleType;
|
||
final isDark = theme.brightness == Brightness.dark;
|
||
|
||
// 柔和背景色(根据明暗模式)
|
||
final accentSoft = isDark ? const Color(0xFF3A2A22) : const Color(0xFFFFE0D6);
|
||
final tertiarySoft = isDark ? AppColors.tertiarySoftDark : AppColors.tertiarySoftLight;
|
||
final roseSoft = isDark ? AppColors.roseSoftDark : AppColors.roseSoftLight;
|
||
final secondarySoft = isDark ? AppColors.secondarySoftDark : AppColors.secondarySoftLight;
|
||
final surfaceWarm = isDark ? AppColors.surfaceWarmDark : AppColors.surfaceWarmLight;
|
||
final borderSoft = isDark ? AppColors.borderSoftDark : AppColors.borderSoftLight;
|
||
final greyBg = isDark ? const Color(0xFF2A2520) : const Color(0xFFF5F0EB);
|
||
|
||
return SingleChildScrollView(
|
||
padding: const EdgeInsets.all(16),
|
||
child: Column(
|
||
children: [
|
||
// ---- 用户头像卡片 ----
|
||
Card(
|
||
elevation: 0,
|
||
shape: RoundedRectangleBorder(borderRadius: AppRadius.lgBorder),
|
||
color: colorScheme.surface,
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(24),
|
||
child: Column(
|
||
children: [
|
||
// 头像 — 渐变背景 + emoji
|
||
Container(
|
||
width: 80,
|
||
height: 80,
|
||
decoration: BoxDecoration(
|
||
shape: BoxShape.circle,
|
||
gradient: LinearGradient(
|
||
begin: Alignment.topLeft,
|
||
end: Alignment.bottomRight,
|
||
colors: [AppColors.accent, AppColors.tertiary],
|
||
),
|
||
),
|
||
alignment: Alignment.center,
|
||
child: Text(
|
||
displayName.isNotEmpty ? displayName[0] : '😊',
|
||
style: const TextStyle(fontSize: 36, color: Colors.white),
|
||
),
|
||
),
|
||
const SizedBox(height: 12),
|
||
// 用户名
|
||
Text(displayName, style: theme.textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)),
|
||
const SizedBox(height: 4),
|
||
// 角色
|
||
Text(
|
||
_roleLabel(role),
|
||
style: theme.textTheme.bodySmall?.copyWith(
|
||
color: colorScheme.onSurface.withValues(alpha: 0.6),
|
||
),
|
||
),
|
||
const SizedBox(height: 4),
|
||
// 签名
|
||
Text(
|
||
user?.displayName ?? '这个人很懒,什么都没写',
|
||
style: theme.textTheme.bodySmall?.copyWith(
|
||
color: colorScheme.onSurface.withValues(alpha: 0.5),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(height: 12),
|
||
|
||
// ---- 统计栏(真实数据) ----
|
||
_LiveStatsBar(borderSoft: borderSoft, colorScheme: colorScheme),
|
||
const SizedBox(height: 20),
|
||
|
||
// ---- 成就徽章(动态加载) ----
|
||
Align(
|
||
alignment: Alignment.centerLeft,
|
||
child: Text('成就徽章', style: theme.textTheme.titleMedium?.copyWith(
|
||
fontWeight: FontWeight.w700,
|
||
)),
|
||
),
|
||
const SizedBox(height: 12),
|
||
SizedBox(
|
||
height: 100,
|
||
child: _AchievementBadges(
|
||
accentSoft: accentSoft,
|
||
tertiarySoft: tertiarySoft,
|
||
roseSoft: roseSoft,
|
||
secondarySoft: secondarySoft,
|
||
),
|
||
),
|
||
const SizedBox(height: 20),
|
||
|
||
// ---- 功能入口 (emoji 图标) ----
|
||
_EmojiMenuItem(emoji: '🏆', bg: tertiarySoft, title: '我的成就', onTap: () => context.go('/achievements')),
|
||
_EmojiMenuItem(emoji: '🎨', bg: roseSoft, title: '贴纸收藏', onTap: () => context.go('/stickers')),
|
||
_EmojiMenuItem(emoji: '📋', bg: secondarySoft, title: '日记模板', onTap: () => context.go('/templates')),
|
||
_EmojiMenuItem(emoji: '👥', bg: accentSoft, title: '我的班级', onTap: () => context.go('/class')),
|
||
_EmojiMenuItem(emoji: '😊', bg: tertiarySoft, title: '心情统计', onTap: () => context.go('/mood')),
|
||
_EmojiMenuItem(emoji: '⚙️', bg: greyBg, title: '设置', onTap: () => context.go('/settings')),
|
||
|
||
if (role != null && role.name == 'teacher')
|
||
_EmojiMenuItem(emoji: '📚', bg: accentSoft, title: '教师管理', onTap: () => context.go('/teacher')),
|
||
if (role != null && role.name == 'parent')
|
||
_EmojiMenuItem(emoji: '👨👩👧', bg: roseSoft, title: '家长中心', onTap: () => context.go('/parent')),
|
||
|
||
const Divider(height: 32),
|
||
|
||
// ---- 开关项 ----
|
||
_EmojiToggleItem(
|
||
emoji: '🔔',
|
||
bg: tertiarySoft,
|
||
title: '消息通知',
|
||
value: true,
|
||
onChanged: (v) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(content: Text(v ? '已开启通知' : '已关闭通知')),
|
||
);
|
||
},
|
||
activeColor: AppColors.accent,
|
||
),
|
||
_EmojiToggleItem(
|
||
emoji: '🌙',
|
||
bg: surfaceWarm,
|
||
title: '深色模式',
|
||
value: theme.brightness == Brightness.dark,
|
||
onChanged: (v) {
|
||
final settings = context.read<SettingsBloc>();
|
||
settings.changeTheme(v ? ThemeMode.dark : ThemeMode.light);
|
||
},
|
||
activeColor: AppColors.accent,
|
||
),
|
||
|
||
const Divider(height: 32),
|
||
|
||
// ---- 更多设置 ----
|
||
_EmojiMenuItem(emoji: '📤', bg: secondarySoft, title: '导出数据', onTap: () {
|
||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('导出功能即将上线')));
|
||
}),
|
||
_EmojiMenuItem(emoji: '💬', bg: accentSoft, title: '意见反馈', onTap: () {
|
||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('感谢你的反馈')));
|
||
}),
|
||
_EmojiMenuItem(
|
||
emoji: 'ℹ️',
|
||
bg: greyBg,
|
||
title: '关于',
|
||
subtitle: '版本 1.0.0',
|
||
onTap: () => context.go('/about'),
|
||
),
|
||
const SizedBox(height: 16),
|
||
|
||
// 退出登录
|
||
SizedBox(
|
||
width: double.infinity,
|
||
child: OutlinedButton(
|
||
onPressed: () {
|
||
context.read<AuthBloc>().add(const LogoutRequested());
|
||
context.go('/login');
|
||
},
|
||
style: OutlinedButton.styleFrom(foregroundColor: colorScheme.error),
|
||
child: const Text('退出登录'),
|
||
),
|
||
),
|
||
const SizedBox(height: DesignTokens.spacing24),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
String _roleLabel(UserRoleType? role) {
|
||
if (role == null) return '未选择角色';
|
||
return switch (role.code) {
|
||
'teacher' => '老师',
|
||
'student' => '学生',
|
||
'parent' => '家长',
|
||
'independent' => '独立用户',
|
||
_ => '用户',
|
||
};
|
||
}
|
||
}
|
||
|
||
/// 统计项
|
||
class _StatItem extends StatelessWidget {
|
||
const _StatItem({required this.label, required this.value, required this.valueColor});
|
||
final String label;
|
||
final String value;
|
||
final Color valueColor;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
return Expanded(
|
||
child: Column(
|
||
children: [
|
||
Text(value, style: theme.textTheme.titleMedium?.copyWith(
|
||
fontWeight: FontWeight.bold, color: valueColor,
|
||
)),
|
||
const SizedBox(height: 4),
|
||
Text(label, style: theme.textTheme.labelSmall?.copyWith(
|
||
color: colorScheme(context).onSurface.withValues(alpha: 0.5),
|
||
)),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
static ColorScheme colorScheme(BuildContext context) => Theme.of(context).colorScheme;
|
||
}
|
||
|
||
/// 成就徽章项
|
||
class _BadgeItem extends StatelessWidget {
|
||
const _BadgeItem({
|
||
required this.emoji,
|
||
required this.name,
|
||
required this.bgColor,
|
||
required this.locked,
|
||
});
|
||
final String emoji;
|
||
final String name;
|
||
final Color bgColor;
|
||
final bool locked;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
return SizedBox(
|
||
width: 80,
|
||
child: Opacity(
|
||
opacity: locked ? 0.5 : 1.0,
|
||
child: Column(
|
||
children: [
|
||
Container(
|
||
width: 56,
|
||
height: 56,
|
||
decoration: BoxDecoration(
|
||
color: locked ? theme.colorScheme.outlineVariant.withValues(alpha: 0.3) : bgColor,
|
||
shape: BoxShape.circle,
|
||
),
|
||
alignment: Alignment.center,
|
||
child: Text(emoji, style: const TextStyle(fontSize: 24)),
|
||
),
|
||
const SizedBox(height: 6),
|
||
Text(name, style: theme.textTheme.labelSmall?.copyWith(fontSize: 11)),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// emoji 菜单项
|
||
class _EmojiMenuItem extends StatelessWidget {
|
||
const _EmojiMenuItem({
|
||
required this.emoji,
|
||
required this.bg,
|
||
required this.title,
|
||
required this.onTap,
|
||
this.subtitle,
|
||
});
|
||
final String emoji;
|
||
final Color bg;
|
||
final String title;
|
||
final String? subtitle;
|
||
final VoidCallback onTap;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
return ListTile(
|
||
leading: Container(
|
||
width: 36,
|
||
height: 36,
|
||
decoration: BoxDecoration(
|
||
color: bg,
|
||
borderRadius: BorderRadius.circular(10),
|
||
),
|
||
alignment: Alignment.center,
|
||
child: Text(emoji, style: const TextStyle(fontSize: 18)),
|
||
),
|
||
title: Text(title, style: theme.textTheme.bodyMedium),
|
||
subtitle: subtitle != null
|
||
? Text(subtitle!, style: theme.textTheme.labelSmall?.copyWith(
|
||
color: theme.colorScheme.onSurface.withValues(alpha: 0.5),
|
||
))
|
||
: null,
|
||
trailing: const Icon(Icons.chevron_right, size: 20),
|
||
onTap: onTap,
|
||
contentPadding: const EdgeInsets.symmetric(horizontal: 4),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// emoji 开关菜单项
|
||
class _EmojiToggleItem extends StatelessWidget {
|
||
const _EmojiToggleItem({
|
||
required this.emoji,
|
||
required this.bg,
|
||
required this.title,
|
||
required this.value,
|
||
required this.onChanged,
|
||
required this.activeColor,
|
||
});
|
||
final String emoji;
|
||
final Color bg;
|
||
final String title;
|
||
final bool value;
|
||
final ValueChanged<bool> onChanged;
|
||
final Color activeColor;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
return ListTile(
|
||
leading: Container(
|
||
width: 36,
|
||
height: 36,
|
||
decoration: BoxDecoration(
|
||
color: bg,
|
||
borderRadius: BorderRadius.circular(10),
|
||
),
|
||
alignment: Alignment.center,
|
||
child: Text(emoji, style: const TextStyle(fontSize: 18)),
|
||
),
|
||
title: Text(title, style: theme.textTheme.bodyMedium),
|
||
trailing: Switch(
|
||
value: value,
|
||
onChanged: onChanged,
|
||
activeColor: activeColor,
|
||
),
|
||
contentPadding: const EdgeInsets.symmetric(horizontal: 4),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 动态统计栏 — 从 JournalRepository 加载真实数据
|
||
class _LiveStatsBar extends StatefulWidget {
|
||
const _LiveStatsBar({required this.borderSoft, required this.colorScheme});
|
||
final Color borderSoft;
|
||
final ColorScheme colorScheme;
|
||
|
||
@override
|
||
State<_LiveStatsBar> createState() => _LiveStatsBarState();
|
||
}
|
||
|
||
class _LiveStatsBarState extends State<_LiveStatsBar> {
|
||
int _totalCount = 0;
|
||
int _streakDays = 0;
|
||
int _monthCount = 0;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_loadStats();
|
||
}
|
||
|
||
Future<void> _loadStats() async {
|
||
try {
|
||
final repo = context.read<JournalRepository>();
|
||
final totalCount = await repo.getJournalCount();
|
||
final journals = await repo.getJournals();
|
||
if (!mounted) return;
|
||
|
||
final today = DateTime.now();
|
||
final monthCount = journals
|
||
.where((j) => j.date.year == today.year && j.date.month == today.month)
|
||
.length;
|
||
|
||
// 推算连续天数
|
||
final dates = journals.map((j) => j.date).toSet();
|
||
var streak = 0;
|
||
var checkDate = today;
|
||
while (dates.contains(DateTime(checkDate.year, checkDate.month, checkDate.day))) {
|
||
streak++;
|
||
checkDate = checkDate.subtract(const Duration(days: 1));
|
||
}
|
||
|
||
setState(() {
|
||
_totalCount = totalCount;
|
||
_streakDays = streak;
|
||
_monthCount = monthCount;
|
||
});
|
||
} catch (e) {
|
||
debugPrint('ProfilePage._loadStats 失败: $e');
|
||
// 保持默认 0 值
|
||
}
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Container(
|
||
decoration: BoxDecoration(
|
||
color: widget.colorScheme.surface,
|
||
borderRadius: AppRadius.mdBorder,
|
||
),
|
||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||
child: Row(
|
||
children: [
|
||
_StatItem(label: '总日记', value: '$_totalCount', valueColor: AppColors.accent),
|
||
VerticalDivider(width: 1, indent: 4, endIndent: 4, color: widget.borderSoft),
|
||
_StatItem(label: '连续天数', value: '$_streakDays', valueColor: AppColors.secondary),
|
||
VerticalDivider(width: 1, indent: 4, endIndent: 4, color: widget.borderSoft),
|
||
_StatItem(label: '本月日记', value: '$_monthCount', valueColor: widget.colorScheme.onSurface),
|
||
VerticalDivider(width: 1, indent: 4, endIndent: 4, color: widget.borderSoft),
|
||
_StatItem(label: '贴纸数', value: '--', valueColor: widget.colorScheme.onSurface),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 成就徽章动态组件 — 从 AchievementBloc 加载真实数据
|
||
class _AchievementBadges extends StatefulWidget {
|
||
const _AchievementBadges({
|
||
required this.accentSoft,
|
||
required this.tertiarySoft,
|
||
required this.roseSoft,
|
||
required this.secondarySoft,
|
||
});
|
||
|
||
final Color accentSoft;
|
||
final Color tertiarySoft;
|
||
final Color roseSoft;
|
||
final Color secondarySoft;
|
||
|
||
@override
|
||
State<_AchievementBadges> createState() => _AchievementBadgesState();
|
||
}
|
||
|
||
class _AchievementBadgesState extends State<_AchievementBadges> {
|
||
late final AchievementBloc _bloc;
|
||
List<Achievement> _achievements = [];
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_bloc = AchievementBloc(api: context.read<ApiClient>());
|
||
_bloc.load();
|
||
_bloc.addListener(_onUpdate);
|
||
}
|
||
|
||
void _onUpdate() {
|
||
if (mounted) {
|
||
setState(() {
|
||
_achievements = _bloc.state.achievements;
|
||
});
|
||
}
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_bloc.removeListener(_onUpdate);
|
||
_bloc.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
if (_achievements.isEmpty) {
|
||
return const Center(child: Text('暂无成就', style: TextStyle(fontSize: 13)));
|
||
}
|
||
|
||
final bgColors = [widget.accentSoft, widget.tertiarySoft, widget.roseSoft, widget.secondarySoft];
|
||
|
||
return ListView.separated(
|
||
scrollDirection: Axis.horizontal,
|
||
itemCount: _achievements.length,
|
||
separatorBuilder: (_, __) => const SizedBox(width: 12),
|
||
itemBuilder: (context, index) {
|
||
final a = _achievements[index];
|
||
return _BadgeItem(
|
||
emoji: a.icon ?? '🏆',
|
||
name: a.name,
|
||
bgColor: bgColors[index % bgColors.length],
|
||
locked: !a.isUnlocked,
|
||
);
|
||
},
|
||
);
|
||
}
|
||
}
|