feat(app): F8 班级系统 + F9/F10 占位页面

F8 班级系统:
- ClassBloc 状态管理 (班级列表/日记墙/成员/主题/评语)
- 班级主页: 日记墙 + 主题布置 Tab + 成员列表
- 老师管理页: 创建班级 + 布置主题 + 点评入口
- 班级码展示 + 评语卡片

F9 家长功能 (占位):
- 家长中心页面框架: 日记查看/心情统计/使用时间/数据管理
- PIPL 合规提示卡片

F10 搜索/设置 (部分):
- 个人中心: 用户信息 + 角色展示 + 功能入口 + 退出登录
- 搜索页: 标签筛选 + 心情过滤 + 搜索框

验证: flutter analyze 0 error
This commit is contained in:
iven
2026-06-01 09:43:54 +08:00
parent 7e3597dc77
commit c4a317c90f
6 changed files with 1678 additions and 21 deletions

View File

@@ -0,0 +1,410 @@
// 班级 BLoC — 管理班级状态、成员、日记墙、主题布置和评语
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:nuanji_app/data/models/journal_entry.dart';
import 'package:nuanji_app/data/models/school_class.dart';
// ===== Events =====
sealed class ClassEvent {
const ClassEvent();
}
/// 加载我的班级列表
final class ClassLoadMyClasses extends ClassEvent {
const ClassLoadMyClasses();
}
/// 选择当前班级
final class ClassSelected extends ClassEvent {
final String classId;
const ClassSelected(this.classId);
}
/// 加载班级成员列表
final class ClassLoadMembers extends ClassEvent {
final String classId;
const ClassLoadMembers(this.classId);
}
/// 加载班级日记墙(已分享到班级的日记)
final class ClassLoadDiaryWall extends ClassEvent {
final String classId;
const ClassLoadDiaryWall(this.classId);
}
/// 加载主题布置列表
final class ClassLoadTopics extends ClassEvent {
final String classId;
const ClassLoadTopics(this.classId);
}
/// 创建班级(老师)
final class ClassCreate extends ClassEvent {
final String name;
final String? schoolName;
const ClassCreate({required this.name, this.schoolName});
}
/// 布置主题(老师)
final class TopicAssign extends ClassEvent {
final String classId;
final String title;
final String? description;
final DateTime? dueDate;
const TopicAssign({
required this.classId,
required this.title,
this.description,
this.dueDate,
});
}
/// 加载日记评语
final class ClassLoadComments extends ClassEvent {
final String journalId;
const ClassLoadComments(this.journalId);
}
// ===== State =====
/// 班级成员模型
class ClassMember {
final String userId;
final String role;
final String? nickname;
final DateTime joinedAt;
const ClassMember({
required this.userId,
required this.role,
this.nickname,
required this.joinedAt,
});
}
/// 主题布置模型
class TopicAssignment {
final String id;
final String classId;
final String teacherId;
final String title;
final String? description;
final DateTime? dueDate;
final bool isActive;
const TopicAssignment({
required this.id,
required this.classId,
required this.teacherId,
required this.title,
this.description,
this.dueDate,
this.isActive = true,
});
}
/// 评语模型
class Comment {
final String id;
final String journalId;
final String authorId;
final String content;
final DateTime createdAt;
const Comment({
required this.id,
required this.journalId,
required this.authorId,
required this.content,
required this.createdAt,
});
}
/// 班级状态
sealed class ClassState {
const ClassState();
}
/// 初始状态
final class ClassInitial extends ClassState {
const ClassInitial();
}
/// 加载中
final class ClassLoading extends ClassState {
const ClassLoading();
}
/// 班级列表已加载
final class ClassListLoaded extends ClassState {
final List<SchoolClass> classes;
final bool isLoading;
const ClassListLoaded({
this.classes = const [],
this.isLoading = false,
});
ClassListLoaded copyWith({
List<SchoolClass>? classes,
bool? isLoading,
}) =>
ClassListLoaded(
classes: classes ?? this.classes,
isLoading: isLoading ?? this.isLoading,
);
}
/// 班级详情已加载(日记墙 / 成员 / 主题 / 评语)
final class ClassDetailLoaded extends ClassState {
final SchoolClass classInfo;
final List<ClassMember> members;
final List<JournalEntry> diaryWall;
final List<TopicAssignment> topics;
final List<Comment> comments;
final bool isLoadingWall;
final bool isLoadingMembers;
final String? selectedJournalId;
const ClassDetailLoaded({
required this.classInfo,
this.members = const [],
this.diaryWall = const [],
this.topics = const [],
this.comments = const [],
this.isLoadingWall = false,
this.isLoadingMembers = false,
this.selectedJournalId,
});
ClassDetailLoaded copyWith({
SchoolClass? classInfo,
List<ClassMember>? members,
List<JournalEntry>? diaryWall,
List<TopicAssignment>? topics,
List<Comment>? comments,
bool? isLoadingWall,
bool? isLoadingMembers,
String? selectedJournalId,
bool clearSelectedJournal = false,
}) =>
ClassDetailLoaded(
classInfo: classInfo ?? this.classInfo,
members: members ?? this.members,
diaryWall: diaryWall ?? this.diaryWall,
topics: topics ?? this.topics,
comments: comments ?? this.comments,
isLoadingWall: isLoadingWall ?? this.isLoadingWall,
isLoadingMembers: isLoadingMembers ?? this.isLoadingMembers,
selectedJournalId: clearSelectedJournal
? null
: (selectedJournalId ?? this.selectedJournalId),
);
}
/// 错误状态
final class ClassError extends ClassState {
final String message;
const ClassError(this.message);
}
// ===== BLoC =====
class ClassBloc extends Bloc<ClassEvent, ClassState> {
ClassBloc() : super(const ClassInitial()) {
on<ClassLoadMyClasses>(_onLoadMyClasses);
on<ClassSelected>(_onClassSelected);
on<ClassLoadMembers>(_onLoadMembers);
on<ClassLoadDiaryWall>(_onLoadDiaryWall);
on<ClassLoadTopics>(_onLoadTopics);
on<ClassCreate>(_onCreateClass);
on<TopicAssign>(_onTopicAssign);
on<ClassLoadComments>(_onLoadComments);
}
Future<void> _onLoadMyClasses(
ClassLoadMyClasses event,
Emitter<ClassState> emit,
) async {
emit(const ClassListLoaded(isLoading: true));
// Phase 1: 占位数据,待 API 集成
await Future.delayed(const Duration(milliseconds: 300));
final now = DateTime.now();
emit(ClassListLoaded(classes: [
SchoolClass(
id: 'class-1',
name: '三年二班',
schoolName: '阳光小学',
teacherId: 'teacher-1',
classCode: 'a1b2c3',
memberCount: 28,
createdAt: now,
updatedAt: now,
),
]));
}
Future<void> _onClassSelected(
ClassSelected event,
Emitter<ClassState> emit,
) async {
final now = DateTime.now();
final classInfo = SchoolClass(
id: event.classId,
name: '三年二班',
schoolName: '阳光小学',
teacherId: 'teacher-1',
classCode: 'a1b2c3',
memberCount: 28,
createdAt: now,
updatedAt: now,
);
emit(ClassDetailLoaded(classInfo: classInfo));
add(ClassLoadDiaryWall(event.classId));
add(ClassLoadMembers(event.classId));
add(ClassLoadTopics(event.classId));
}
Future<void> _onLoadMembers(
ClassLoadMembers event,
Emitter<ClassState> emit,
) async {
if (state is! ClassDetailLoaded) return;
final current = state as ClassDetailLoaded;
emit(current.copyWith(isLoadingMembers: true));
await Future.delayed(const Duration(milliseconds: 200));
final now = DateTime.now();
final members = List.generate(
28,
(i) => ClassMember(
userId: 'user-$i',
role: i == 0 ? 'teacher' : 'student',
nickname: i == 0 ? '王老师' : '同学$i',
joinedAt: now,
),
);
emit(current.copyWith(members: members, isLoadingMembers: false));
}
Future<void> _onLoadDiaryWall(
ClassLoadDiaryWall event,
Emitter<ClassState> emit,
) async {
if (state is! ClassDetailLoaded) return;
final current = state as ClassDetailLoaded;
emit(current.copyWith(isLoadingWall: true));
await Future.delayed(const Duration(milliseconds: 200));
final now = DateTime.now();
final titles = ['快乐的周末', '春天来了', '我的小猫', '数学课', '好朋友', '下雨天'];
final diaries = List.generate(
6,
(i) => JournalEntry(
id: 'diary-$i',
authorId: 'user-${i + 1}',
classId: event.classId,
title: titles[i],
date: now.subtract(Duration(days: i)),
mood: Mood.values[i % Mood.values.length],
tags: const ['日常', '心情'],
createdAt: now,
updatedAt: now,
),
);
emit(current.copyWith(diaryWall: diaries, isLoadingWall: false));
}
Future<void> _onLoadTopics(
ClassLoadTopics event,
Emitter<ClassState> emit,
) async {
if (state is! ClassDetailLoaded) return;
final current = state as ClassDetailLoaded;
final topics = [
TopicAssignment(
id: 'topic-1',
classId: event.classId,
teacherId: 'teacher-1',
title: '我的周末',
description: '写一篇关于你周末生活的日记',
dueDate: DateTime.now().add(const Duration(days: 3)),
),
TopicAssignment(
id: 'topic-2',
classId: event.classId,
teacherId: 'teacher-1',
title: '最开心的一天',
description: '回忆一下让你最开心的一天',
),
];
emit(current.copyWith(topics: topics));
}
Future<void> _onCreateClass(
ClassCreate event,
Emitter<ClassState> emit,
) async {
final newClass = SchoolClass.create(
name: event.name,
schoolName: event.schoolName ?? '',
teacherId: 'current-user',
classCode: 'x1y2z3',
);
if (state is ClassListLoaded) {
final current = state as ClassListLoaded;
emit(current.copyWith(classes: [...current.classes, newClass]));
}
}
Future<void> _onTopicAssign(
TopicAssign event,
Emitter<ClassState> emit,
) async {
if (state is! ClassDetailLoaded) return;
final current = state as ClassDetailLoaded;
final newTopic = TopicAssignment(
id: 'topic-${DateTime.now().millisecondsSinceEpoch}',
classId: event.classId,
teacherId: 'current-user',
title: event.title,
description: event.description,
dueDate: event.dueDate,
);
emit(current.copyWith(topics: [newTopic, ...current.topics]));
}
Future<void> _onLoadComments(
ClassLoadComments event,
Emitter<ClassState> emit,
) async {
if (state is! ClassDetailLoaded) return;
final current = state as ClassDetailLoaded;
final comments = [
Comment(
id: 'comment-1',
journalId: event.journalId,
authorId: 'teacher-1',
content: '写得很好,继续保持!',
createdAt: DateTime.now(),
),
];
emit(current.copyWith(
comments: comments,
selectedJournalId: event.journalId,
));
}
}

View File

@@ -1,14 +1,516 @@
import 'package:flutter/material.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/data/models/journal_entry.dart';
import 'package:nuanji_app/data/models/school_class.dart';
import '../bloc/class_bloc.dart';
/// 班级主页 — 日记墙 + 班级信息
class ClassPage extends StatelessWidget {
const ClassPage({super.key});
@override
Widget build(BuildContext context) {
return const Scaffold(
body: Center(
child: Text('班级 - 占位页面'),
return BlocProvider(
create: (context) => ClassBloc()..add(const ClassLoadMyClasses()),
child: const _ClassView(),
);
}
}
class _ClassView extends StatelessWidget {
const _ClassView();
@override
Widget build(BuildContext context) {
return BlocBuilder<ClassBloc, ClassState>(
builder: (context, state) {
if (state is ClassLoading || state is ClassInitial) {
return Scaffold(
appBar: AppBar(title: const Text('班级')),
body: Center(child: CircularProgressIndicator()),
);
}
if (state is ClassError) {
return Scaffold(
appBar: AppBar(title: const Text('班级')),
body: Center(child: Text(state.message)),
);
}
if (state is ClassListLoaded) {
return _ClassListView(classes: state.classes, colorScheme: Theme.of(context).colorScheme);
}
if (state is ClassDetailLoaded) {
return _ClassDetailView(state: state);
}
return const SizedBox.shrink();
},
);
}
}
// ===== 班级列表视图 =====
class _ClassListView extends StatelessWidget {
const _ClassListView({required this.classes, required this.colorScheme});
final List<SchoolClass> classes;
final ColorScheme colorScheme;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('我的班级')),
body: classes.isEmpty
? _buildEmptyState(context, colorScheme)
: ListView(
padding: const EdgeInsets.all(16),
children: classes.map((cls) {
return _ClassListCard(
cls: cls,
colorScheme: colorScheme,
onTap: () =>
context.read<ClassBloc>().add(ClassSelected(cls.id)),
);
}).toList(),
),
);
}
Widget _buildEmptyState(BuildContext context, ColorScheme colorScheme) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.groups_outlined, size: 64, color: colorScheme.onSurface.withValues(alpha: 0.2)),
const SizedBox(height: 16),
Text('还没有加入任何班级', style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurface.withValues(alpha: 0.5),
)),
const SizedBox(height: 24),
FilledButton.tonal(
onPressed: () => context.go('/class-code'),
child: const Text('输入班级码加入'),
),
],
),
);
}
}
class _ClassListCard extends StatelessWidget {
const _ClassListCard({
required this.cls,
required this.colorScheme,
required this.onTap,
});
final SchoolClass cls;
final ColorScheme colorScheme;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Card(
margin: const EdgeInsets.only(bottom: 12),
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(color: colorScheme.outlineVariant),
),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(16),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: AppColors.secondary.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(12),
),
alignment: Alignment.center,
child: const Icon(Icons.school_rounded, color: AppColors.secondary),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(cls.name, style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600)),
const SizedBox(height: 4),
Text(
'${cls.schoolName} · ${cls.memberCount}',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withValues(alpha: 0.5),
),
),
],
),
),
const Icon(Icons.chevron_right),
],
),
),
),
);
}
}
// ===== 班级详情视图(日记墙)=====
class _ClassDetailView extends StatelessWidget {
const _ClassDetailView({required this.state});
final ClassDetailLoaded state;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final classInfo = state.classInfo;
return DefaultTabController(
length: 3,
child: Scaffold(
appBar: AppBar(
leading: IconButton(
onPressed: () => context.read<ClassBloc>().add(const ClassLoadMyClasses()),
icon: const Icon(Icons.arrow_back),
),
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(classInfo.name),
Text(
'${classInfo.schoolName} · 班级码: ${classInfo.classCode}',
style: theme.textTheme.labelSmall?.copyWith(
color: colorScheme.onSurface.withValues(alpha: 0.5),
),
),
],
),
bottom: const TabBar(
tabs: [
Tab(text: '日记墙'),
Tab(text: '主题'),
Tab(text: '成员'),
],
),
),
body: TabBarView(
children: [
// 日记墙
_DiaryWallTab(state: state),
// 主题布置
_TopicsTab(topics: state.topics),
// 成员列表
_MembersTab(state: state),
],
),
),
);
}
}
// ===== 日记墙 Tab =====
class _DiaryWallTab extends StatelessWidget {
const _DiaryWallTab({required this.state});
final ClassDetailLoaded state;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
if (state.isLoadingWall) {
return const Center(child: CircularProgressIndicator());
}
if (state.diaryWall.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.auto_stories_outlined, size: 48, color: colorScheme.onSurface.withValues(alpha: 0.2)),
const SizedBox(height: 12),
Text('日记墙还是空的', style: theme.textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurface.withValues(alpha: 0.5),
)),
const SizedBox(height: 8),
Text('分享你的日记到班级吧!', style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withValues(alpha: 0.3),
)),
],
),
);
}
return ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: state.diaryWall.length,
itemBuilder: (context, index) {
final journal = state.diaryWall[index];
return _DiaryWallCard(journal: journal, comments: state.comments);
},
);
}
}
class _DiaryWallCard extends StatelessWidget {
const _DiaryWallCard({required this.journal, required this.comments});
final JournalEntry journal;
final List<Comment> comments;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final moodColor = AppColors.moodColors[journal.mood.value] ?? colorScheme.primary;
return Card(
margin: const EdgeInsets.only(bottom: 12),
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(color: colorScheme.outlineVariant),
),
child: InkWell(
onTap: () => context.go('/editor?id=${journal.id}'),
borderRadius: BorderRadius.circular(16),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 头部:作者 + 心情
Row(
children: [
CircleAvatar(
radius: 16,
backgroundColor: AppColors.rose.withValues(alpha: 0.2),
child: Text(
'',
style: theme.textTheme.labelMedium?.copyWith(color: AppColors.rose),
),
),
const SizedBox(width: 8),
Expanded(
child: Text(
journal.title,
style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600),
),
),
Container(
width: 28,
height: 28,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: moodColor.withValues(alpha: 0.15),
),
alignment: Alignment.center,
child: Text(_moodEmoji(journal.mood), style: const TextStyle(fontSize: 14)),
),
],
),
const SizedBox(height: 8),
// 日期
Text(
'${journal.date.month}${journal.date.day}',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withValues(alpha: 0.4),
),
),
// 评语
if (comments.isNotEmpty) ...[
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: colorScheme.primaryContainer.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
const Icon(Icons.rate_review_rounded, size: 14),
const SizedBox(width: 4),
Expanded(
child: Text(
comments.first.content,
style: theme.textTheme.bodySmall,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
],
],
),
),
),
);
}
String _moodEmoji(Mood mood) => switch (mood) {
Mood.happy => '😊',
Mood.calm => '😌',
Mood.sad => '😢',
Mood.angry => '😠',
Mood.thinking => '🤔',
};
}
// ===== 主题 Tab =====
class _TopicsTab extends StatelessWidget {
const _TopicsTab({required this.topics});
final List<TopicAssignment> topics;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
if (topics.isEmpty) {
return Center(
child: Text('暂无主题布置', style: theme.textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurface.withValues(alpha: 0.5),
)),
);
}
return ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: topics.length,
itemBuilder: (context, index) {
final topic = topics[index];
final isOverdue = topic.dueDate != null && topic.dueDate!.isBefore(DateTime.now());
return Card(
margin: const EdgeInsets.only(bottom: 12),
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(color: colorScheme.outlineVariant),
),
child: InkWell(
onTap: () => context.go('/editor?topic=${topic.id}'),
borderRadius: BorderRadius.circular(16),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.assignment_outlined, size: 20),
const SizedBox(width: 8),
Expanded(
child: Text(
topic.title,
style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600),
),
),
if (topic.isActive && !isOverdue)
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: AppColors.secondary.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(6),
),
child: Text('进行中', style: theme.textTheme.labelSmall?.copyWith(color: AppColors.secondary)),
),
],
),
if (topic.description != null) ...[
const SizedBox(height: 8),
Text(topic.description!, style: theme.textTheme.bodyMedium),
],
if (topic.dueDate != null) ...[
const SizedBox(height: 8),
Text(
'截止: ${topic.dueDate!.month}${topic.dueDate!.day}',
style: theme.textTheme.bodySmall?.copyWith(
color: isOverdue ? colorScheme.error : colorScheme.onSurface.withValues(alpha: 0.5),
),
),
],
],
),
),
),
);
},
);
}
}
// ===== 成员 Tab =====
class _MembersTab extends StatelessWidget {
const _MembersTab({required this.state});
final ClassDetailLoaded state;
@override
Widget build(BuildContext context) {
if (state.isLoadingMembers) {
return const Center(child: CircularProgressIndicator());
}
final theme = Theme.of(context);
return ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: state.members.length,
itemBuilder: (context, index) {
final member = state.members[index];
final isTeacher = member.role == 'teacher';
return ListTile(
leading: CircleAvatar(
backgroundColor: isTeacher
? AppColors.accent.withValues(alpha: 0.15)
: AppColors.secondary.withValues(alpha: 0.15),
child: Text(
(member.nickname ?? '?').characters.first,
style: TextStyle(
color: isTeacher ? AppColors.accent : AppColors.secondary,
fontWeight: FontWeight.bold,
),
),
),
title: Text(member.nickname ?? '未知'),
trailing: isTeacher
? Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: AppColors.accent.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(6),
),
child: Text('老师', style: theme.textTheme.labelSmall?.copyWith(color: AppColors.accent)),
)
: null,
);
},
);
}
}