Files
nj/app/lib/features/class_/views/class_page.dart
iven 346c751cbb
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
refactor(app): 迁移 4 个页面到共享 EmptyStateWidget + ErrorStateWidget
迁移统计:
- discover_page: _buildError → ErrorStateWidget, _buildEmptyHint → EmptyStateWidget
- sticker_library_page: 错误 + 空列表 → 共享组件
- class_page: 错误/班级列表空/日记墙空/话题空 → 共享组件 (4 处)
- calendar_page: CalendarError → ErrorStateWidget

统一体验: 所有页面空状态使用一致的 icon + title + subtitle + CTA 布局
2026-06-07 13:42:56 +08:00

551 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 '../../../widgets/empty_state_widget.dart';
import '../../../widgets/error_state_widget.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: ErrorStateWidget(
message: state.message,
onRetry: () => context.read<ClassBloc>().add(const ClassLoadMyClasses()),
),
);
}
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 EmptyStateWidget(
icon: Icons.group_add_rounded,
title: '还没有加入班级',
actionLabel: '通过班级码加入',
onAction: () => context.go('/class-code'),
);
}
}
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 const EmptyStateWidget(
icon: Icons.auto_stories_rounded,
title: '日记墙还是空的',
subtitle: '分享你的日记到这里吧',
iconSize: 48,
);
}
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),
),
),
// 评语(按 journalId 过滤,避免显示在错误卡片下)
...(() {
final journalComments = comments.where((c) => c.journalId == journal.id).toList();
if (journalComments.isEmpty) return <Widget>[];
return [
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(
journalComments.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 const EmptyStateWidget(
icon: Icons.assignment_outlined,
title: '暂无主题布置',
);
}
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,
);
},
);
}
}