Files
nj/app/lib/features/profile/views/profile_page.dart
iven 7e928ae1e1
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
fix(app): 修复 P2~P4 共 10 项前端问题
P2 必须修复:
- 教师布置主题 classId 从硬编码改为班级下拉选择器
- 班级日记墙使用服务端 classId 过滤替代前端过滤
- Profile 统计栏接入 JournalRepository 真实数据
- WeeklyPage 从全硬编码改为 JournalRepository 数据驱动

P3 建议改进:
- 提取 mood_utils.dart 公共函数,消除 4 处重复定义
- 贴纸库搜索框连接 StickerBloc 按名称过滤

P4 细节打磨:
- 家长页多孩子时显示 DropdownButton 选择器
- 搜索结果日记卡片点击跳转 /editor?id=
- MonthlyPage 照片数量从 JournalElement 统计
- calendar_page/mood_page/search_page 统一使用 moodToEmoji/moodToLabel
2026-06-02 20:21:51 +08:00

438 lines
15 KiB
Dart
Raw 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';
/// 个人中心页面
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: const Text('😊', style: TextStyle(fontSize: 36)),
),
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: ListView(
scrollDirection: Axis.horizontal,
children: [
_BadgeItem(emoji: '📝', name: '初出茅庐', bgColor: accentSoft, locked: false),
const SizedBox(width: 12),
_BadgeItem(emoji: '🔥', name: '七日连续', bgColor: tertiarySoft, locked: false),
const SizedBox(width: 12),
_BadgeItem(emoji: '🎨', name: '装饰达人', bgColor: roseSoft, locked: false),
const SizedBox(width: 12),
_BadgeItem(emoji: '🌟', name: '人气之星', bgColor: secondarySoft, locked: true),
const SizedBox(width: 12),
_BadgeItem(emoji: '🏆', name: '写作高手', bgColor: accentSoft, locked: true),
const SizedBox(width: 12),
_BadgeItem(emoji: '💎', name: '全能王', bgColor: tertiarySoft, locked: true),
],
),
),
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 (_) {
// 保持默认 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),
],
),
);
}
}