feat(app): 设置页 UI + Mood/成就/贴纸 BLoC 接入 API + B7 测试扩展

前端改动:
- 新建设置页面 (主题切换/关于/隐私政策/用户协议/儿童隐私保护)
- SettingsBloc 注册到 MultiRepositoryProvider 全局可访问
- MoodBloc 修复编译错误 + 接入 /diary/stats/mood API
- MoodPage 添加错误状态展示和重试按钮
- AchievementBloc + 页面改造接入 /diary/achievements API
- StickerBloc + 页面改造接入 /diary/sticker-packs API
- TemplateBloc + 页面改造接入 /diary/templates API
- ProfilePage 设置入口改为跳转 /settings
- 添加 /settings 路由

后端改动:
- 扩展 mood_stats_service 测试 (连续天数算法/心情计数/边界场景)
- 新增 class_service 测试 (班级码生成/唯一性/错误映射)
- 新增 achievement_service 测试 (DTO 结构/序列化/map 构建)
- 新增 sticker_service 测试 (DTO 序列化/错误处理)
- 扩展 dto.rs 测试 (achievement/mood_stats/sticker/template/notification)
- 清理 2 个 unused import warning

验证:
- cargo check 0 error 0 warning
- flutter analyze 0 error
This commit is contained in:
iven
2026-06-01 11:19:43 +08:00
parent 860e9e5d22
commit 8331db63ba
19 changed files with 1749 additions and 326 deletions

View File

@@ -0,0 +1,499 @@
// 设置页面 — 主题切换 + 关于 + 隐私政策
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:nuanji_app/core/theme/app_colors.dart';
import 'package:nuanji_app/features/profile/bloc/settings_bloc.dart';
/// 设置页面
class SettingsPage extends StatelessWidget {
const SettingsPage({super.key});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Scaffold(
appBar: AppBar(title: const Text('设置')),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// ===== 外观设置 =====
_SectionHeader(title: '外观', theme: theme),
const SizedBox(height: 8),
const _ThemeSelectorCard(),
const SizedBox(height: 24),
// ===== 关于 =====
_SectionHeader(title: '关于', theme: theme),
const SizedBox(height: 8),
_AboutCard(colorScheme: colorScheme),
const SizedBox(height: 24),
// ===== 法律信息 =====
_SectionHeader(title: '法律信息', theme: theme),
const SizedBox(height: 8),
_LegalCard(colorScheme: colorScheme),
const SizedBox(height: 32),
// ===== 底部版本号 =====
Center(
child: Text(
'暖记 v1.0.0 (Phase 1)',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withValues(alpha: 0.4),
),
),
),
const SizedBox(height: 16),
],
),
),
);
}
}
// ===== 分区标题 =====
class _SectionHeader extends StatelessWidget {
const _SectionHeader({required this.title, required this.theme});
final String title;
final ThemeData theme;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(left: 4),
child: Text(
title,
style: theme.textTheme.titleSmall?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w600,
),
),
);
}
}
// ===== 主题选择器卡片 =====
class _ThemeSelectorCard extends StatelessWidget {
const _ThemeSelectorCard();
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final settingsBloc = context.read<SettingsBloc>();
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(22),
side: BorderSide(color: colorScheme.outlineVariant),
),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
children: [
// 标题行
Row(
children: [
Icon(Icons.palette_outlined, color: colorScheme.primary),
const SizedBox(width: 12),
Text(
'主题模式',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
],
),
const SizedBox(height: 16),
// 三选一 SegmentedButton实时响应主题切换
ListenableBuilder(
listenable: settingsBloc,
builder: (context, _) {
final current = settingsBloc.state.themeMode;
return SegmentedButton<ThemeMode>(
segments: const [
ButtonSegment(
value: ThemeMode.system,
icon: Icon(Icons.brightness_auto),
label: Text('跟随系统'),
),
ButtonSegment(
value: ThemeMode.light,
icon: Icon(Icons.light_mode),
label: Text('浅色'),
),
ButtonSegment(
value: ThemeMode.dark,
icon: Icon(Icons.dark_mode),
label: Text('深色'),
),
],
selected: {current},
onSelectionChanged: (modes) {
settingsBloc.changeTheme(modes.first);
},
);
},
),
],
),
),
);
}
}
// ===== 关于卡片 =====
class _AboutCard extends StatelessWidget {
const _AboutCard({required this.colorScheme});
final ColorScheme colorScheme;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(22),
side: BorderSide(color: colorScheme.outlineVariant),
),
child: Column(
children: [
// 应用 Logo 信息
Padding(
padding: const EdgeInsets.all(20),
child: Row(
children: [
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: AppColors.accent.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(16),
),
alignment: Alignment.center,
child: const Text('📝', style: TextStyle(fontSize: 28)),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'暖记',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 2),
Text(
'温暖治愈的手账日记',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withValues(alpha: 0.6),
),
),
],
),
),
],
),
),
const Divider(height: 1, indent: 20, endIndent: 20),
_SettingsTile(
icon: Icons.info_outline,
iconColor: colorScheme.primary,
title: '版本信息',
subtitle: 'v1.0.0 · Phase 1',
onTap: () => _showAboutDialog(context),
),
_SettingsTile(
icon: Icons.favorite_outline,
iconColor: AppColors.accent,
title: '给暖记评分',
subtitle: '你的鼓励是我们前进的动力',
onTap: () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('应用商店评分功能即将上线')),
);
},
),
_SettingsTile(
icon: Icons.feedback_outlined,
iconColor: AppColors.secondary,
title: '意见反馈',
subtitle: '告诉我们你的想法',
onTap: () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('反馈功能即将上线')),
);
},
),
],
),
);
}
void _showAboutDialog(BuildContext context) {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Row(
children: [
Text('📝', style: TextStyle(fontSize: 24)),
SizedBox(width: 8),
Text('暖记'),
],
),
content: const Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('温暖治愈的手账日记 App'),
SizedBox(height: 8),
Text('版本: v1.0.0 (Phase 1)'),
SizedBox(height: 8),
Text('面向小学生首发,以手写/涂鸦为核心输入方式。'),
SizedBox(height: 16),
Text(
'© 2026 暖记团队',
style: TextStyle(fontSize: 12),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('确定'),
),
],
),
);
}
}
// ===== 法律信息卡片 =====
class _LegalCard extends StatelessWidget {
const _LegalCard({required this.colorScheme});
final ColorScheme colorScheme;
@override
Widget build(BuildContext context) {
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(22),
side: BorderSide(color: colorScheme.outlineVariant),
),
child: Column(
children: [
_SettingsTile(
icon: Icons.shield_outlined,
iconColor: colorScheme.primary,
title: '隐私政策',
subtitle: '了解我们如何保护你的数据',
onTap: () => _showLegalDialog(context, page: _LegalPage.privacy),
),
_SettingsTile(
icon: Icons.description_outlined,
iconColor: AppColors.tertiary,
title: '用户协议',
subtitle: '使用条款和服务说明',
onTap: () => _showLegalDialog(context, page: _LegalPage.terms),
),
_SettingsTile(
icon: Icons.child_care_outlined,
iconColor: AppColors.secondary,
title: '儿童隐私保护',
subtitle: '我们如何保护未成年人的权益',
subtitleColor: AppColors.secondary,
onTap: () => _showLegalDialog(context, page: _LegalPage.child),
),
],
),
);
}
void _showLegalDialog(BuildContext context, {required _LegalPage page}) {
final (title, icon, content) = switch (page) {
_LegalPage.privacy => (
'隐私政策',
'🔒',
'''
暖记隐私政策最后更新2026年6月
一、我们收集的信息
• 昵称和年级(不收集真实姓名和身份证号)
• 日记内容(仅存储在你的设备上,云端加密存储)
• 心情记录(仅用于统计分析)
二、信息使用
• 提供日记手账服务
• 班级互动功能
• 个性化体验
三、信息保护
• 所有数据传输采用 TLS 加密
• 云端数据采用 AES-256-GCM 加密
• 本地数据采用设备级加密
四、你的权利
• 查阅、更正你的个人数据
• 要求删除所有数据
• 导出你的数据
五、儿童保护
• 未满 14 岁需家长授权
• 最小必要数据原则
• 家长可管理孩子的数据
如有疑问请联系privacy@nuanji.app''',
),
_LegalPage.terms => (
'用户协议',
'📋',
'''
暖记用户协议最后更新2026年6月
一、服务说明
暖记是一款面向小学生的手账日记应用,提供日记书写、心情记录、班级互动等功能。
二、用户行为规范
• 尊重他人,友善交流
• 不发布不当内容
• 遵守学校规章制度
三、内容归属
• 用户创作的日记内容归用户所有
• 暖记不会在未经授权的情况下使用用户内容
四、免责声明
• 因不可抗力导致的服务中断不承担责任
• 用户因不当使用导致的损失自行承担
五、协议修改
• 修改协议将提前通知用户
• 继续使用视为同意修改后的协议''',
),
_LegalPage.child => (
'儿童隐私保护',
'👶',
'''
暖记特别重视儿童个人信息保护,严格遵守《儿童个人信息网络保护规定》。
一、家长授权
• 未满 14 周岁的用户需家长同意后方可使用
• 注册时需完成家长授权验证
• 家长可随时撤回授权
二、最小必要原则
• 仅收集提供服务必需的最少数据
• 不收集真实姓名、身份证号等敏感信息
• 不进行用户画像和个性化广告推送
三、数据安全
• 采用多重加密保护儿童数据
• 严格限制数据访问权限
• 定期进行安全审计
四、家长权利
• 查阅孩子的所有数据
• 要求更正错误数据
• 要求删除孩子数据30天内完成
• 导出孩子数据
五、账号注销
• 注销后30天内删除所有关联数据
• 包括日记、贴纸、成就等
• 删除后不可恢复
六、内容安全
• 自动过滤敏感内容
• 老师可审核班级内容
• 举报机制保护儿童安全
如需联系child-safety@nuanji.app''',
),
};
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: Row(
children: [
Text(icon, style: const TextStyle(fontSize: 24)),
const SizedBox(width: 8),
Expanded(child: Text(title)),
],
),
content: SingleChildScrollView(
child: Text(
content.trim(),
style: const TextStyle(fontSize: 13, height: 1.6),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('我知道了'),
),
],
),
);
}
}
enum _LegalPage { privacy, terms, child }
// ===== 通用设置列表项 =====
class _SettingsTile extends StatelessWidget {
const _SettingsTile({
required this.icon,
required this.iconColor,
required this.title,
required this.onTap,
this.subtitle,
this.subtitleColor,
});
final IconData icon;
final Color iconColor;
final String title;
final String? subtitle;
final Color? subtitleColor;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return ListTile(
leading: Icon(icon, color: iconColor),
title: Text(title, style: theme.textTheme.bodyMedium),
subtitle: subtitle != null
? Text(
subtitle!,
style: theme.textTheme.bodySmall?.copyWith(
color: subtitleColor ??
theme.colorScheme.onSurface.withValues(alpha: 0.5),
),
)
: null,
trailing: const Icon(Icons.chevron_right, size: 20),
onTap: onTap,
contentPadding: const EdgeInsets.symmetric(horizontal: 20),
);
}
}