diff --git a/app/lib/features/class_/bloc/class_bloc.dart b/app/lib/features/class_/bloc/class_bloc.dart new file mode 100644 index 0000000..fd7f8fd --- /dev/null +++ b/app/lib/features/class_/bloc/class_bloc.dart @@ -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 classes; + final bool isLoading; + + const ClassListLoaded({ + this.classes = const [], + this.isLoading = false, + }); + + ClassListLoaded copyWith({ + List? classes, + bool? isLoading, + }) => + ClassListLoaded( + classes: classes ?? this.classes, + isLoading: isLoading ?? this.isLoading, + ); +} + +/// 班级详情已加载(日记墙 / 成员 / 主题 / 评语) +final class ClassDetailLoaded extends ClassState { + final SchoolClass classInfo; + final List members; + final List diaryWall; + final List topics; + final List 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? members, + List? diaryWall, + List? topics, + List? 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 { + ClassBloc() : super(const ClassInitial()) { + on(_onLoadMyClasses); + on(_onClassSelected); + on(_onLoadMembers); + on(_onLoadDiaryWall); + on(_onLoadTopics); + on(_onCreateClass); + on(_onTopicAssign); + on(_onLoadComments); + } + + Future _onLoadMyClasses( + ClassLoadMyClasses event, + Emitter 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 _onClassSelected( + ClassSelected event, + Emitter 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 _onLoadMembers( + ClassLoadMembers event, + Emitter 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 _onLoadDiaryWall( + ClassLoadDiaryWall event, + Emitter 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 _onLoadTopics( + ClassLoadTopics event, + Emitter 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 _onCreateClass( + ClassCreate event, + Emitter 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 _onTopicAssign( + TopicAssign event, + Emitter 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 _onLoadComments( + ClassLoadComments event, + Emitter 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, + )); + } +} diff --git a/app/lib/features/class_/views/class_page.dart b/app/lib/features/class_/views/class_page.dart index 4e70b73..dbd4b4d 100644 --- a/app/lib/features/class_/views/class_page.dart +++ b/app/lib/features/class_/views/class_page.dart @@ -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( + 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 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().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().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 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 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, + ); + }, + ); + } +} diff --git a/app/lib/features/parent/views/parent_page.dart b/app/lib/features/parent/views/parent_page.dart index 4be1e9b..968149e 100644 --- a/app/lib/features/parent/views/parent_page.dart +++ b/app/lib/features/parent/views/parent_page.dart @@ -1,13 +1,187 @@ -import 'package:flutter/material.dart'; +// 家长页面 — 只读查看 + 孩子数据管理 +import 'package:flutter/material.dart'; +import 'package:nuanji_app/core/theme/app_colors.dart'; + +/// 家长中心页面 — 家长查看孩子日记和统计 class ParentPage extends StatelessWidget { const ParentPage({super.key}); @override Widget build(BuildContext context) { - return const Scaffold( - body: Center( - child: Text('家长 - 占位页面'), + 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: [ + // 孩子信息卡片 + Card( + elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(22)), + color: colorScheme.primaryContainer, + child: Padding( + padding: const EdgeInsets.all(20), + child: Row( + children: [ + CircleAvatar( + radius: 28, + backgroundColor: AppColors.rose.withValues(alpha: 0.2), + child: const Text('👶', style: TextStyle(fontSize: 24)), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('孩子的日记', style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)), + const SizedBox(height: 4), + Text('查看和管理孩子的日记数据', style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurface.withValues(alpha: 0.6), + )), + ], + ), + ), + ], + ), + ), + ), + const SizedBox(height: 20), + + // 功能列表 + _ParentActionCard( + icon: Icons.auto_stories_outlined, + iconColor: AppColors.accent, + title: '日记查看', + subtitle: '只读查看孩子的日记和评语', + onTap: () => ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('F9: 家长日记查看待实现')), + ), + ), + const SizedBox(height: 12), + _ParentActionCard( + icon: Icons.bar_chart_outlined, + iconColor: AppColors.secondary, + title: '心情统计', + subtitle: '查看孩子的写作频率和心情趋势', + onTap: () => ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('F9: 心情统计待实现')), + ), + ), + const SizedBox(height: 12), + _ParentActionCard( + icon: Icons.timer_outlined, + iconColor: AppColors.tertiary, + title: '使用时间', + subtitle: '设置孩子每天的使用时间限制', + onTap: () => ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('F9: 使用时间限制待实现')), + ), + ), + const SizedBox(height: 12), + _ParentActionCard( + icon: Icons.download_outlined, + iconColor: colorScheme.primary, + title: '数据管理', + subtitle: '导出或删除孩子的日记数据', + onTap: () => ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('F9: 数据管理待实现')), + ), + ), + const SizedBox(height: 24), + + // PIPL 提示 + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Icon(Icons.shield_outlined, size: 18, color: colorScheme.onSurface.withValues(alpha: 0.5)), + const SizedBox(width: 8), + Expanded( + child: Text( + '根据《个人信息保护法》,您有权查阅、更正、删除和导出孩子的数据。', + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurface.withValues(alpha: 0.5), + ), + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +class _ParentActionCard extends StatelessWidget { + const _ParentActionCard({ + required this.icon, + required this.iconColor, + required this.title, + required this.subtitle, + required this.onTap, + }); + + final IconData icon; + final Color iconColor; + final String title; + final String subtitle; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return Card( + 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: 44, + height: 44, + decoration: BoxDecoration( + color: iconColor.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(12), + ), + child: Icon(icon, color: iconColor, size: 22), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600)), + const SizedBox(height: 2), + Text(subtitle, style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurface.withValues(alpha: 0.5), + )), + ], + ), + ), + Icon(Icons.chevron_right, color: colorScheme.onSurface.withValues(alpha: 0.3)), + ], + ), + ), ), ); } diff --git a/app/lib/features/profile/views/profile_page.dart b/app/lib/features/profile/views/profile_page.dart index d75109f..5e03e45 100644 --- a/app/lib/features/profile/views/profile_page.dart +++ b/app/lib/features/profile/views/profile_page.dart @@ -1,14 +1,175 @@ -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/features/auth/bloc/auth_bloc.dart'; + +/// 个人中心页面 class ProfilePage extends StatelessWidget { const ProfilePage({super.key}); @override Widget build(BuildContext context) { - return const Scaffold( - body: Center( - child: Text('我的 - 占位页面'), + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final authState = context.watch().state; + final displayName = authState is Authenticated ? authState.user.displayLabel : '用户'; + final role = authState is Authenticated ? authState.user.primaryRoleType : null; + + return SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + // 用户头像卡片 + Card( + elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(22)), + color: colorScheme.primaryContainer, + child: Padding( + padding: const EdgeInsets.all(24), + child: Row( + children: [ + CircleAvatar( + radius: 32, + backgroundColor: colorScheme.primary.withValues(alpha: 0.2), + child: Text( + displayName.characters.first, + style: theme.textTheme.headlineSmall?.copyWith(color: colorScheme.primary), + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(displayName, style: theme.textTheme.titleMedium?.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: 20), + + // 功能入口 + _ProfileMenuItem( + icon: Icons.auto_awesome_outlined, + iconColor: AppColors.accent, + title: '我的成就', + onTap: () => context.go('/achievements'), + ), + _ProfileMenuItem( + icon: Icons.emoji_emotions_outlined, + iconColor: AppColors.secondary, + title: '贴纸收藏', + onTap: () => context.go('/stickers'), + ), + _ProfileMenuItem( + icon: Icons.dashboard_customize_outlined, + iconColor: AppColors.tertiary, + title: '日记模板', + onTap: () => context.go('/templates'), + ), + _ProfileMenuItem( + icon: Icons.groups_outlined, + iconColor: colorScheme.primary, + title: '我的班级', + onTap: () => context.go('/class'), + ), + if (role != null && role.name == 'teacher') + _ProfileMenuItem( + icon: Icons.school_outlined, + iconColor: AppColors.accent, + title: '教师管理', + onTap: () => context.go('/teacher'), + ), + if (role != null && role.name == 'parent') + _ProfileMenuItem( + icon: Icons.family_restroom_outlined, + iconColor: AppColors.rose, + title: '家长中心', + onTap: () => context.go('/parent'), + ), + const Divider(height: 32), + _ProfileMenuItem( + icon: Icons.bar_chart_outlined, + iconColor: colorScheme.primary, + title: '心情统计', + onTap: () => context.go('/mood'), + ), + _ProfileMenuItem( + icon: Icons.settings_outlined, + iconColor: colorScheme.onSurface.withValues(alpha: 0.5), + title: '设置', + onTap: () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('设置页面开发中')), + ); + }, + ), + const SizedBox(height: 16), + + // 退出登录 + SizedBox( + width: double.infinity, + child: OutlinedButton( + onPressed: () { + context.read().add(const LogoutRequested()); + context.go('/login'); + }, + style: OutlinedButton.styleFrom(foregroundColor: colorScheme.error), + child: const Text('退出登录'), + ), + ), + ], ), ); } + + String _roleLabel(dynamic role) { + if (role == null) return '未选择角色'; + return switch (role.name) { + 'teacher' => '老师', + 'student' => '学生', + 'parent' => '家长', + 'independent' => '独立用户', + _ => '用户', + }; + } +} + +class _ProfileMenuItem extends StatelessWidget { + const _ProfileMenuItem({ + required this.icon, + required this.iconColor, + required this.title, + required this.onTap, + }); + + final IconData icon; + final Color iconColor; + final String title; + 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), + trailing: const Icon(Icons.chevron_right, size: 20), + onTap: onTap, + contentPadding: const EdgeInsets.symmetric(horizontal: 4), + ); + } } diff --git a/app/lib/features/search/views/search_page.dart b/app/lib/features/search/views/search_page.dart index fe08940..4abdaed 100644 --- a/app/lib/features/search/views/search_page.dart +++ b/app/lib/features/search/views/search_page.dart @@ -1,14 +1,160 @@ -import 'package:flutter/material.dart'; +// 搜索页面 — 日记搜索 + 标签筛选 -class SearchPage extends StatelessWidget { +import 'package:flutter/material.dart'; +import 'package:nuanji_app/core/theme/app_colors.dart'; +import 'package:nuanji_app/data/models/journal_entry.dart'; + +/// 搜索页面 — 全文搜索日记(Phase 1 占位 UI) +class SearchPage extends StatefulWidget { const SearchPage({super.key}); + @override + State createState() => _SearchPageState(); +} + +class _SearchPageState extends State { + final _searchController = TextEditingController(); + bool _hasSearched = false; + + // Phase 1 占位数据 + final _recentTags = ['日常', '学校', '旅行', '美食', '读书', '心情']; + final _moodFilters = Mood.values; + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { - return const Scaffold( - body: Center( - child: Text('搜索 - 占位页面'), + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return Scaffold( + appBar: AppBar( + title: TextField( + controller: _searchController, + decoration: InputDecoration( + hintText: '搜索日记...', + hintStyle: theme.textTheme.bodyLarge?.copyWith( + color: colorScheme.onSurface.withValues(alpha: 0.4), + ), + border: InputBorder.none, + prefixIcon: const Icon(Icons.search), + suffixIcon: _searchController.text.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + _searchController.clear(); + setState(() => _hasSearched = false); + }, + ) + : null, + ), + textInputAction: TextInputAction.search, + onSubmitted: (_) => _doSearch(), + ), + ), + body: _hasSearched + ? _buildSearchResults(context, colorScheme) + : _buildSuggestions(context, theme, colorScheme), + ); + } + + void _doSearch() { + setState(() => _hasSearched = true); + } + + Widget _buildSuggestions(BuildContext context, ThemeData theme, ColorScheme colorScheme) { + return SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('常用标签', style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600)), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: _recentTags.map((tag) { + return ActionChip( + label: Text(tag), + onPressed: () { + _searchController.text = tag; + _doSearch(); + }, + ); + }).toList(), + ), + const SizedBox(height: 24), + Text('按心情筛选', style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600)), + const SizedBox(height: 12), + Wrap( + spacing: 12, + runSpacing: 12, + children: _moodFilters.map((mood) { + final color = AppColors.moodColors[mood.value] ?? colorScheme.primary; + return GestureDetector( + onTap: () { + _searchController.text = _moodLabel(mood); + _doSearch(); + }, + child: Column( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: color.withValues(alpha: 0.15), + ), + alignment: Alignment.center, + child: Text(_moodEmoji(mood), style: const TextStyle(fontSize: 24)), + ), + const SizedBox(height: 4), + Text(_moodLabel(mood), style: theme.textTheme.labelSmall), + ], + ), + ); + }).toList(), + ), + ], ), ); } + + Widget _buildSearchResults(BuildContext context, ColorScheme colorScheme) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.search_off_rounded, size: 48, color: colorScheme.onSurface.withValues(alpha: 0.2)), + const SizedBox(height: 12), + Text( + 'Phase 1: 搜索功能待 Isar FTS 集成', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurface.withValues(alpha: 0.5), + ), + ), + ], + ), + ); + } + + String _moodEmoji(Mood mood) => switch (mood) { + Mood.happy => '😊', + Mood.calm => '😌', + Mood.sad => '😢', + Mood.angry => '😠', + Mood.thinking => '🤔', + }; + + String _moodLabel(Mood mood) => switch (mood) { + Mood.happy => '开心', + Mood.calm => '平静', + Mood.sad => '难过', + Mood.angry => '生气', + Mood.thinking => '思考', + }; } diff --git a/app/lib/features/teacher/views/teacher_page.dart b/app/lib/features/teacher/views/teacher_page.dart index d3d01b7..fbfdb5a 100644 --- a/app/lib/features/teacher/views/teacher_page.dart +++ b/app/lib/features/teacher/views/teacher_page.dart @@ -1,13 +1,277 @@ -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 '../../class_/bloc/class_bloc.dart'; + +/// 老师管理页面 — 教师专属功能入口 class TeacherPage extends StatelessWidget { const TeacherPage({super.key}); @override Widget build(BuildContext context) { - return const Scaffold( - body: Center( - child: Text('教师 - 占位页面'), + return BlocProvider( + create: (context) => ClassBloc()..add(const ClassLoadMyClasses()), + child: const _TeacherView(), + ); + } +} + +class _TeacherView extends StatelessWidget { + const _TeacherView(); + + @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: [ + // 创建班级卡片 + _ActionCard( + icon: Icons.add_circle_outline, + iconColor: AppColors.accent, + title: '创建班级', + subtitle: '创建新班级并邀请学生加入', + onTap: () => _showCreateClassDialog(context), + ), + const SizedBox(height: 12), + + // 布置主题卡片 + _ActionCard( + icon: Icons.assignment_outlined, + iconColor: AppColors.secondary, + title: '布置主题', + subtitle: '给班级布置日记写作主题', + onTap: () => _showAssignTopicDialog(context), + ), + const SizedBox(height: 12), + + // 班级码管理 + _ActionCard( + icon: Icons.qr_code, + iconColor: AppColors.tertiary, + title: '班级码管理', + subtitle: '查看和重置班级码', + onTap: () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('班级码: a1b2c3')), + ); + }, + ), + const SizedBox(height: 24), + + // 最近点评 + Text( + '快捷功能', + style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 12), + _ActionCard( + icon: Icons.rate_review_outlined, + iconColor: AppColors.rose, + title: '点评日记', + subtitle: '查看学生日记并点评', + onTap: () => context.go('/class'), + ), + const SizedBox(height: 12), + _ActionCard( + icon: Icons.bar_chart_outlined, + iconColor: colorScheme.primary, + title: '班级统计', + subtitle: '查看班级写作活跃度', + onTap: () => context.go('/mood'), + ), + ], + ), + ), + ); + } + + void _showCreateClassDialog(BuildContext context) { + final nameController = TextEditingController(); + final schoolController = TextEditingController(); + + showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('创建班级'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: nameController, + decoration: const InputDecoration( + labelText: '班级名称', + hintText: '例如: 三年二班', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 12), + TextField( + controller: schoolController, + decoration: const InputDecoration( + labelText: '学校名称(可选)', + hintText: '例如: 阳光小学', + border: OutlineInputBorder(), + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: const Text('取消'), + ), + FilledButton( + onPressed: () { + if (nameController.text.trim().isNotEmpty) { + context.read().add(ClassCreate( + name: nameController.text.trim(), + schoolName: schoolController.text.trim().isEmpty + ? null + : schoolController.text.trim(), + )); + Navigator.pop(dialogContext); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('班级创建成功!')), + ); + } + }, + child: const Text('创建'), + ), + ], + ), + ); + } + + void _showAssignTopicDialog(BuildContext context) { + final titleController = TextEditingController(); + final descController = TextEditingController(); + + showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('布置主题'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: titleController, + decoration: const InputDecoration( + labelText: '主题标题', + hintText: '例如: 我的周末', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 12), + TextField( + controller: descController, + decoration: const InputDecoration( + labelText: '描述(可选)', + hintText: '主题要求和说明', + border: OutlineInputBorder(), + ), + maxLines: 3, + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: const Text('取消'), + ), + FilledButton( + onPressed: () { + if (titleController.text.trim().isNotEmpty) { + context.read().add(TopicAssign( + classId: 'class-1', + title: titleController.text.trim(), + description: descController.text.trim().isEmpty + ? null + : descController.text.trim(), + )); + Navigator.pop(dialogContext); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('主题布置成功!')), + ); + } + }, + child: const Text('布置'), + ), + ], + ), + ); + } +} + +/// 操作卡片 +class _ActionCard extends StatelessWidget { + const _ActionCard({ + required this.icon, + required this.iconColor, + required this.title, + required this.subtitle, + required this.onTap, + }); + + final IconData icon; + final Color iconColor; + final String title; + final String subtitle; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return Card( + 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: 44, + height: 44, + decoration: BoxDecoration( + color: iconColor.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(12), + ), + child: Icon(icon, color: iconColor, size: 22), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600)), + const SizedBox(height: 2), + Text(subtitle, style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurface.withValues(alpha: 0.5), + )), + ], + ), + ), + Icon(Icons.chevron_right, color: colorScheme.onSurface.withValues(alpha: 0.3)), + ], + ), + ), ), ); }