Files
nj/app/lib/features/profile/views/profile_page.dart
iven 49d4aa36a7
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
fix(app): Phase 1.1 紧急修复 — SyncEngine 接入 + authorId + catch 异常处理
- feat(sync): SyncEngine 接入 EditorPage, 保存时 enqueue + 网络恢复自动 trySync
- fix(editor): authorId 从 AuthBloc 获取, 替代硬编码 'local'
- fix(bloc): class_bloc/calendar/profile/parent catch(_).全部改为 debugPrint
- feat(editor): 编辑器工具栏拆分 (brush_panel/tag_panel/text_format_bar/dot_grid_painter)
- feat(editor): EditorBloc 扩展 + EditorPage 增强
- feat(search): SearchBloc 扩展搜索功能
- feat(home): HomeBloc/HomePage 增强
- feat(auth): LoginPage 增强
- feat(templates): TemplateGalleryPage 重构
- fix(web): 管理端班级/日记页面修复
- fix(server): comment_service + theme_handler 修复
- docs: 添加全链路审计报告和验证截图
2026-06-02 21:21:43 +08:00

439 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 (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),
],
),
);
}
}