- SearchPage: 热搜词从日记标签频率动态生成 + 模板搜索网格 - ProfilePage: 成就徽章从 AchievementBloc 动态加载 + 头像首字母 - TeacherPage: 班级码改为对话框展示 (班级名+码+人数) - StickerLibraryPage: 分类从 API 动态合并 + 精选包卡片动态化 - TemplateGalleryPage: 适配动态数据 - ClassPage: 微调 - HomePage: 路由适配 - CalendarBloc: 新增测试 - AppRouter: 路由更新
564 lines
18 KiB
Dart
564 lines
18 KiB
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/core/theme/app_radius.dart';
|
|
import 'package:nuanji_app/data/models/journal_entry.dart';
|
|
import 'package:nuanji_app/data/models/school_class.dart';
|
|
import 'package:nuanji_app/data/repositories/class_repository.dart';
|
|
import 'package:nuanji_app/data/repositories/journal_repository.dart';
|
|
import '../../auth/bloc/auth_bloc.dart';
|
|
import '../bloc/class_bloc.dart';
|
|
import '../widgets/comment_bottom_sheet.dart';
|
|
|
|
/// 班级主页 — 日记墙 + 班级信息
|
|
class ClassPage extends StatelessWidget {
|
|
const ClassPage({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return BlocProvider(
|
|
create: (context) => ClassBloc(
|
|
classRepository: context.read<ClassRepository>(),
|
|
journalRepository: context.read<JournalRepository>(),
|
|
)..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: AppRadius.mdBorder,
|
|
side: BorderSide(color: colorScheme.outlineVariant),
|
|
),
|
|
child: InkWell(
|
|
onTap: onTap,
|
|
borderRadius: AppRadius.mdBorder,
|
|
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: AppRadius.mdBorder,
|
|
side: BorderSide(color: colorScheme.outlineVariant),
|
|
),
|
|
child: InkWell(
|
|
onTap: () => context.push('/editor?id=${journal.id}'),
|
|
borderRadius: AppRadius.mdBorder,
|
|
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(
|
|
journal.title.isNotEmpty ? journal.title[0] : '日',
|
|
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: AppRadius.smBorder,
|
|
),
|
|
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,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
// 写评语按钮(仅老师可见)
|
|
if (_isTeacher(context)) ...[
|
|
const SizedBox(height: 8),
|
|
TextButton.icon(
|
|
onPressed: () {
|
|
showModalBottomSheet(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
builder: (_) => CommentBottomSheet(
|
|
journalId: journal.id,
|
|
studentName: journal.authorId, // TODO: 替换为真实昵称
|
|
onSubmit: (content) {
|
|
context.read<ClassBloc>().add(
|
|
CommentCreate(
|
|
journalId: journal.id,
|
|
content: content,
|
|
),
|
|
);
|
|
},
|
|
),
|
|
);
|
|
},
|
|
icon: const Icon(Icons.rate_review_outlined, size: 16),
|
|
label: const Text('写评语'),
|
|
style: TextButton.styleFrom(
|
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
|
minimumSize: const Size(0, 36),
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// 判断当前用户是否是老师
|
|
bool _isTeacher(BuildContext context) {
|
|
final authState = context.read<AuthBloc>().state;
|
|
if (authState is Authenticated) {
|
|
return authState.user.isTeacher;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
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: AppRadius.mdBorder,
|
|
side: BorderSide(color: colorScheme.outlineVariant),
|
|
),
|
|
child: InkWell(
|
|
onTap: () => context.push('/editor?topic=${topic.id}'),
|
|
borderRadius: AppRadius.mdBorder,
|
|
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,
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|