Files
nj/app/lib/features/profile/views/profile_page.dart
iven f64355946c
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
feat(app): 共享 UI 组件 + 4 个关键 UX bug 修复
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)
- 贴纸数统计改为 '--' 占位 (之前错误显示日记总数)
2026-06-07 13:36:10 +08:00

504 lines
16 KiB
Dart
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
// 个人中心页面 — 用户信息 + 功能入口 + 设置
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,
);
},
);
}
}