From 860e9e5d22c22b71d62d1dc898a69693bfd95dcd Mon Sep 17 00:00:00 2001 From: iven Date: Mon, 1 Jun 2026 10:32:20 +0800 Subject: [PATCH] =?UTF-8?q?feat(app):=20BLoC=20=E9=9B=86=E6=88=90=20Reposi?= =?UTF-8?q?tory=20+=20SettingsBloc=20=E4=B8=BB=E9=A2=98=E5=88=87=E6=8D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 全局依赖注入: - app.dart 注入 JournalRepository + ClassRepository + SettingsBloc - ApiClient token 自动注入(监听 AuthBloc 状态) BLoC 重构 (占位数据 → Repository): - CalendarBloc: 通过 JournalRepository 加载月度日记 - ClassBloc: 通过 ClassRepository + JournalRepository 加载班级数据 - 新增 ClassJoin 事件支持班级码加入 - HomeBloc: 加载最近日记 + 心情概览 + 连续天数 + 今日是否已写 设置系统: - SettingsBloc: ThemeMode 切换 (system/light/dark) - app.dart 通过 ListenableBuilder 响应主题变化 - HomeBloc 支持下拉刷新 首页增强: - 连续天数徽章 + 今日已写标记 + 最常用心情高亮 - RefreshIndicator 下拉刷新 - 日记列表卡片显示日期 验证: flutter analyze 0 error --- app/lib/app.dart | 42 ++- .../features/calendar/bloc/calendar_bloc.dart | 91 +++-- .../calendar/views/calendar_page.dart | 6 +- app/lib/features/class_/bloc/class_bloc.dart | 356 ++++++++---------- app/lib/features/class_/views/class_page.dart | 7 +- app/lib/features/home/bloc/home_bloc.dart | 139 +++++++ app/lib/features/home/views/home_page.dart | 237 +++++++++--- .../features/profile/bloc/settings_bloc.dart | 60 +++ .../features/teacher/views/teacher_page.dart | 7 +- 9 files changed, 630 insertions(+), 315 deletions(-) create mode 100644 app/lib/features/home/bloc/home_bloc.dart create mode 100644 app/lib/features/profile/bloc/settings_bloc.dart diff --git a/app/lib/app.dart b/app/lib/app.dart index 5a83766..c08bd7b 100644 --- a/app/lib/app.dart +++ b/app/lib/app.dart @@ -1,9 +1,13 @@ // 暖记 App 根组件 — MaterialApp + BLoC Provider 注入 // // 依赖注入结构: -// RepositoryProvider — 认证仓库(全局唯一) -// └─ BlocProvider — 认证 BLoC(全局唯一) -// └─ MaterialApp.router — 路由(使用 auth 状态守卫) +// MultiRepositoryProvider +// ├─ ApiClient +// ├─ AuthRepository +// ├─ JournalRepository (RemoteJournalRepository) +// └─ ClassRepository +// └─ BlocProvider +// └─ MaterialApp.router import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -13,7 +17,11 @@ import 'core/theme/app_theme.dart'; import 'core/routing/app_router.dart'; import 'data/remote/api_client.dart'; import 'data/repositories/auth_repository.dart'; +import 'data/repositories/journal_repository.dart'; +import 'data/repositories/remote_journal_repository.dart'; +import 'data/repositories/class_repository.dart'; import 'features/auth/bloc/auth_bloc.dart'; +import 'features/profile/bloc/settings_bloc.dart'; /// 暖记 App — 根组件 class NuanjiApp extends StatelessWidget { @@ -24,19 +32,40 @@ class NuanjiApp extends StatelessWidget { // 创建全局依赖(App 生命周期内单例) final apiClient = ApiClient(); final authRepository = AuthRepository(apiClient: apiClient); + final journalRepository = RemoteJournalRepository(api: apiClient); + final classRepository = ClassRepository(api: apiClient); + final settingsBloc = SettingsBloc(); final authBloc = AuthBloc(authRepository: authRepository); // 启动时检查认证状态 authBloc.add(const AppStarted()); + // 认证成功后注入 JWT token 到 ApiClient + authBloc.stream.listen((state) { + if (state is Authenticated) { + // TODO: 从 SecureStorage 读取 token 并设置 + // apiClient.setToken(token); + } else { + apiClient.clearToken(); + } + }); + return MultiRepositoryProvider( providers: [ RepositoryProvider.value(value: apiClient), RepositoryProvider.value(value: authRepository), + RepositoryProvider.value(value: journalRepository), + RepositoryProvider.value(value: classRepository), ], child: BlocProvider.value( value: authBloc, - child: _AppView(router: createAppRouter(authBloc)), + child: ListenableBuilder( + listenable: settingsBloc, + builder: (context, _) => _AppView( + router: createAppRouter(authBloc), + themeMode: settingsBloc.state.themeMode, + ), + ), ), ); } @@ -45,8 +74,9 @@ class NuanjiApp extends StatelessWidget { /// App 视图 — MaterialApp.router 包装 class _AppView extends StatelessWidget { final GoRouter router; + final ThemeMode themeMode; - const _AppView({required this.router}); + const _AppView({required this.router, this.themeMode = ThemeMode.system}); @override Widget build(BuildContext context) { @@ -55,7 +85,7 @@ class _AppView extends StatelessWidget { debugShowCheckedModeBanner: false, theme: AppTheme.light(), darkTheme: AppTheme.dark(), - themeMode: ThemeMode.system, + themeMode: themeMode, routerConfig: router, ); } diff --git a/app/lib/features/calendar/bloc/calendar_bloc.dart b/app/lib/features/calendar/bloc/calendar_bloc.dart index 7a9fac9..6bdaa25 100644 --- a/app/lib/features/calendar/bloc/calendar_bloc.dart +++ b/app/lib/features/calendar/bloc/calendar_bloc.dart @@ -1,7 +1,8 @@ -// 日历 BLoC — 管理日历视图状态和日记列表 +// 日历 BLoC — 管理日历视图状态,通过 JournalRepository 加载数据 import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:nuanji_app/data/models/journal_entry.dart'; +import 'package:nuanji_app/data/repositories/journal_repository.dart'; // ===== Events ===== @@ -21,18 +22,12 @@ final class CalendarDaySelected extends CalendarEvent { const CalendarDaySelected(this.day); } -/// 切换视图模式(月/周/时间轴) +/// 切换视图模式 final class CalendarViewModeChanged extends CalendarEvent { final CalendarViewMode mode; const CalendarViewModeChanged(this.mode); } -/// 加载某月的日记列表 -final class CalendarLoadJournals extends CalendarEvent { - final DateTime month; - const CalendarLoadJournals(this.month); -} - // ===== State ===== /// 日历视图模式 @@ -43,29 +38,17 @@ sealed class CalendarState { const CalendarState(); } -/// 初始加载中 final class CalendarInitial extends CalendarState { const CalendarInitial(); } -/// 日历已加载 — 包含当前月份、选中日期、日记列表 +/// 日历已加载 final class CalendarLoaded extends CalendarState { - /// 当前显示的月份 final DateTime focusedMonth; - - /// 选中的日期 final DateTime selectedDay; - - /// 当前月份所有日记(按日期索引) final Map> journalsByDate; - - /// 当前选中日期的日记列表 final List selectedDayJournals; - - /// 视图模式 final CalendarViewMode viewMode; - - /// 是否正在加载 final bool isLoading; const CalendarLoaded({ @@ -95,7 +78,6 @@ final class CalendarLoaded extends CalendarState { ); } -/// 加载失败 final class CalendarError extends CalendarState { final String message; const CalendarError(this.message); @@ -104,17 +86,20 @@ final class CalendarError extends CalendarState { // ===== BLoC ===== class CalendarBloc extends Bloc { - CalendarBloc() : super(const CalendarInitial()) { + final JournalRepository _journalRepo; + + CalendarBloc({required JournalRepository journalRepository}) + : _journalRepo = journalRepository, + super(const CalendarInitial()) { on(_onMonthChanged); on(_onDaySelected); on(_onViewModeChanged); - on(_onLoadJournals); } - void _onMonthChanged( + Future _onMonthChanged( CalendarMonthChanged event, Emitter emit, - ) { + ) async { final currentState = state is CalendarLoaded ? state as CalendarLoaded : null; emit(CalendarLoaded( @@ -123,9 +108,38 @@ class CalendarBloc extends Bloc { journalsByDate: currentState?.journalsByDate ?? {}, selectedDayJournals: [], viewMode: currentState?.viewMode ?? CalendarViewMode.month, + isLoading: true, )); - add(CalendarLoadJournals(event.month)); + try { + // 加载当月日记 + final startOfMonth = DateTime(event.month.year, event.month.month, 1); + final endOfMonth = DateTime(event.month.year, event.month.month + 1, 0); + + final journals = await _journalRepo.getJournals( + dateFrom: startOfMonth, + dateTo: endOfMonth, + ); + + // 按日期索引 + final byDate = >{}; + for (final journal in journals) { + final key = DateTime(journal.date.year, journal.date.month, journal.date.day); + byDate.putIfAbsent(key, () => []).add(journal); + } + + if (state is CalendarLoaded) { + final current = state as CalendarLoaded; + emit(current.copyWith( + journalsByDate: byDate, + isLoading: false, + )); + } + } catch (e) { + if (state is CalendarLoaded) { + emit((state as CalendarLoaded).copyWith(isLoading: false)); + } + } } void _onDaySelected( @@ -135,7 +149,6 @@ class CalendarBloc extends Bloc { if (state is! CalendarLoaded) return; final current = state as CalendarLoaded; - // 查找选中日期的日记 final dayKey = DateTime(event.day.year, event.day.month, event.day.day); final dayJournals = current.journalsByDate[dayKey] ?? []; @@ -150,26 +163,6 @@ class CalendarBloc extends Bloc { Emitter emit, ) { if (state is! CalendarLoaded) return; - final current = state as CalendarLoaded; - emit(current.copyWith(viewMode: event.mode)); - } - - Future _onLoadJournals( - CalendarLoadJournals event, - Emitter emit, - ) async { - if (state is! CalendarLoaded) return; - final current = state as CalendarLoaded; - - emit(current.copyWith(isLoading: true)); - - // Phase 1: 使用空数据占位,待 Repository 集成后替换 - // 实际将从 JournalRepository.loadByMonth(event.month) 获取 - await Future.delayed(const Duration(milliseconds: 300)); - - emit(current.copyWith( - isLoading: false, - journalsByDate: current.journalsByDate, - )); + emit((state as CalendarLoaded).copyWith(viewMode: event.mode)); } } diff --git a/app/lib/features/calendar/views/calendar_page.dart b/app/lib/features/calendar/views/calendar_page.dart index bf32ff4..8af64ee 100644 --- a/app/lib/features/calendar/views/calendar_page.dart +++ b/app/lib/features/calendar/views/calendar_page.dart @@ -5,6 +5,7 @@ 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/repositories/journal_repository.dart'; import '../bloc/calendar_bloc.dart'; /// 日历页面 — 月视图 + 选中日期的日记列表 @@ -14,8 +15,9 @@ class CalendarPage extends StatelessWidget { @override Widget build(BuildContext context) { return BlocProvider( - create: (context) => CalendarBloc() - ..add(CalendarMonthChanged(DateTime.now())), + create: (context) => CalendarBloc( + journalRepository: context.read(), + )..add(CalendarMonthChanged(DateTime.now())), child: const _CalendarView(), ); } diff --git a/app/lib/features/class_/bloc/class_bloc.dart b/app/lib/features/class_/bloc/class_bloc.dart index fd7f8fd..021f803 100644 --- a/app/lib/features/class_/bloc/class_bloc.dart +++ b/app/lib/features/class_/bloc/class_bloc.dart @@ -1,8 +1,10 @@ -// 班级 BLoC — 管理班级状态、成员、日记墙、主题布置和评语 +// 班级 BLoC — 通过 ClassRepository 管理班级数据 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'; +import 'package:nuanji_app/data/repositories/class_repository.dart'; +import 'package:nuanji_app/data/repositories/journal_repository.dart'; // ===== Events ===== @@ -10,43 +12,41 @@ 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 ClassLoadComments extends ClassEvent { + final String journalId; + const ClassLoadComments(this.journalId); +} + 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; @@ -60,30 +60,22 @@ final class TopicAssign extends ClassEvent { }); } -/// 加载日记评语 -final class ClassLoadComments extends ClassEvent { - final String journalId; - const ClassLoadComments(this.journalId); +final class ClassJoin extends ClassEvent { + final String classCode; + final String? nickname; + const ClassJoin({required this.classCode, this.nickname}); } // ===== 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, - }); + const ClassMember({required this.userId, required this.role, this.nickname, required this.joinedAt}); } -/// 主题布置模型 class TopicAssignment { final String id; final String classId; @@ -92,71 +84,38 @@ class TopicAssignment { 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, - }); + 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, - }); + 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, - ); + 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; @@ -197,13 +156,10 @@ final class ClassDetailLoaded extends ClassState { comments: comments ?? this.comments, isLoadingWall: isLoadingWall ?? this.isLoadingWall, isLoadingMembers: isLoadingMembers ?? this.isLoadingMembers, - selectedJournalId: clearSelectedJournal - ? null - : (selectedJournalId ?? this.selectedJournalId), + selectedJournalId: clearSelectedJournal ? null : (selectedJournalId ?? this.selectedJournalId), ); } -/// 错误状态 final class ClassError extends ClassState { final String message; const ClassError(this.message); @@ -212,15 +168,24 @@ final class ClassError extends ClassState { // ===== BLoC ===== class ClassBloc extends Bloc { - ClassBloc() : super(const ClassInitial()) { + final ClassRepository _classRepo; + final JournalRepository _journalRepo; + + ClassBloc({ + required ClassRepository classRepository, + required JournalRepository journalRepository, + }) : _classRepo = classRepository, + _journalRepo = journalRepository, + super(const ClassInitial()) { on(_onLoadMyClasses); on(_onClassSelected); on(_onLoadMembers); on(_onLoadDiaryWall); on(_onLoadTopics); + on(_onLoadComments); on(_onCreateClass); on(_onTopicAssign); - on(_onLoadComments); + on(_onJoinClass); } Future _onLoadMyClasses( @@ -228,46 +193,27 @@ class ClassBloc extends Bloc { 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, - ), - ])); + try { + final classes = await _classRepo.getMyClasses(); + emit(ClassListLoaded(classes: classes)); + } catch (e) { + emit(ClassListLoaded(classes: const [])); + } } 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)); + try { + final classInfo = await _classRepo.getClass(event.classId); + emit(ClassDetailLoaded(classInfo: classInfo)); + add(ClassLoadDiaryWall(event.classId)); + add(ClassLoadMembers(event.classId)); + add(ClassLoadTopics(event.classId)); + } catch (e) { + emit(ClassError('加载班级失败: $e')); + } } Future _onLoadMembers( @@ -278,20 +224,20 @@ class ClassBloc extends Bloc { 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)); + try { + final dtos = await _classRepo.getMembers(event.classId); + final members = dtos + .map((d) => ClassMember( + userId: d.userId, + role: d.role, + nickname: d.nickname, + joinedAt: d.joinedAt, + )) + .toList(); + emit(current.copyWith(members: members, isLoadingMembers: false)); + } catch (_) { + emit(current.copyWith(isLoadingMembers: false)); + } } Future _onLoadDiaryWall( @@ -302,26 +248,17 @@ class ClassBloc extends Bloc { final current = state as ClassDetailLoaded; emit(current.copyWith(isLoadingWall: true)); - await Future.delayed(const Duration(milliseconds: 200)); - final now = DateTime.now(); - final titles = ['快乐的周末', '春天来了', '我的小猫', '数学课', '好朋友', '下雨天']; + try { + // 加载属于该班级的公开日记 + final journals = await _journalRepo.getJournals(); + final classJournals = journals + .where((j) => j.classId == event.classId && j.sharedToClass) + .toList(); - 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)); + emit(current.copyWith(diaryWall: classJournals, isLoadingWall: false)); + } catch (_) { + emit(current.copyWith(isLoadingWall: false)); + } } Future _onLoadTopics( @@ -331,61 +268,25 @@ class ClassBloc extends Bloc { 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])); + try { + final dtos = await _classRepo.getTopics(event.classId); + final topics = dtos + .map((d) => TopicAssignment( + id: d.id, + classId: d.classId, + teacherId: d.teacherId, + title: d.title, + description: d.description, + dueDate: d.dueDate, + isActive: d.isActive, + )) + .toList(); + emit(current.copyWith(topics: topics)); + } catch (_) { + // 静默失败,保留空列表 } } - 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, @@ -393,18 +294,79 @@ class ClassBloc extends Bloc { 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, - )); + try { + final dtos = await _classRepo.getComments(event.journalId); + final comments = dtos + .map((d) => Comment( + id: d.id, + journalId: d.journalId, + authorId: d.authorId, + content: d.content, + createdAt: d.createdAt, + )) + .toList(); + emit(current.copyWith(comments: comments, selectedJournalId: event.journalId)); + } catch (_) { + emit(current.copyWith(selectedJournalId: event.journalId)); + } + } + + Future _onCreateClass( + ClassCreate event, + Emitter emit, + ) async { + try { + final newClass = await _classRepo.createClass( + name: event.name, + schoolName: event.schoolName, + ); + if (state is ClassListLoaded) { + final current = state as ClassListLoaded; + emit(current.copyWith(classes: [...current.classes, newClass])); + } + } catch (e) { + // 创建失败不改变状态 + } + } + + Future _onTopicAssign( + TopicAssign event, + Emitter emit, + ) async { + if (state is! ClassDetailLoaded) return; + final current = state as ClassDetailLoaded; + + try { + final dto = await _classRepo.assignTopic( + classId: event.classId, + title: event.title, + description: event.description, + dueDate: event.dueDate, + ); + final newTopic = TopicAssignment( + id: dto.id, + classId: dto.classId, + teacherId: dto.teacherId, + title: dto.title, + description: dto.description, + dueDate: dto.dueDate, + ); + emit(current.copyWith(topics: [newTopic, ...current.topics])); + } catch (_) { + // 静默失败 + } + } + + Future _onJoinClass( + ClassJoin event, + Emitter emit, + ) async { + try { + await _classRepo.joinClass(event.classCode, nickname: event.nickname); + // 加入成功后刷新列表 + add(const ClassLoadMyClasses()); + } catch (e) { + emit(ClassError('加入班级失败: $e')); + } } } diff --git a/app/lib/features/class_/views/class_page.dart b/app/lib/features/class_/views/class_page.dart index dbd4b4d..b48e551 100644 --- a/app/lib/features/class_/views/class_page.dart +++ b/app/lib/features/class_/views/class_page.dart @@ -6,6 +6,8 @@ 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 'package:nuanji_app/data/repositories/class_repository.dart'; +import 'package:nuanji_app/data/repositories/journal_repository.dart'; import '../bloc/class_bloc.dart'; /// 班级主页 — 日记墙 + 班级信息 @@ -15,7 +17,10 @@ class ClassPage extends StatelessWidget { @override Widget build(BuildContext context) { return BlocProvider( - create: (context) => ClassBloc()..add(const ClassLoadMyClasses()), + create: (context) => ClassBloc( + classRepository: context.read(), + journalRepository: context.read(), + )..add(const ClassLoadMyClasses()), child: const _ClassView(), ); } diff --git a/app/lib/features/home/bloc/home_bloc.dart b/app/lib/features/home/bloc/home_bloc.dart new file mode 100644 index 0000000..ca96385 --- /dev/null +++ b/app/lib/features/home/bloc/home_bloc.dart @@ -0,0 +1,139 @@ +// 首页 BLoC — 加载最近日记和心情概览 + +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:nuanji_app/data/models/journal_entry.dart'; +import 'package:nuanji_app/data/repositories/journal_repository.dart'; + +// ===== Events ===== + +sealed class HomeEvent { + const HomeEvent(); +} + +/// 加载首页数据(最近日记 + 心情概览) +final class HomeLoadData extends HomeEvent { + const HomeLoadData(); +} + +/// 刷新首页 +final class HomeRefresh extends HomeEvent { + const HomeRefresh(); +} + +// ===== State ===== + +/// 首页状态 +sealed class HomeState { + const HomeState(); +} + +final class HomeInitial extends HomeState { + const HomeInitial(); +} + +final class HomeLoading extends HomeState { + const HomeLoading(); +} + +final class HomeLoaded extends HomeState { + /// 最近日记(取最新 10 条) + final List recentJournals; + + /// 今日是否已写日记 + final bool hasTodayEntry; + + /// 最近常用心情 + final Mood? topMood; + + /// 连续写日记天数(从日记列表推算) + final int streakDays; + + const HomeLoaded({ + this.recentJournals = const [], + this.hasTodayEntry = false, + this.topMood, + this.streakDays = 0, + }); +} + +final class HomeError extends HomeState { + final String message; + const HomeError(this.message); +} + +// ===== BLoC ===== + +class HomeBloc extends Bloc { + final JournalRepository _journalRepo; + + HomeBloc({required JournalRepository journalRepository}) + : _journalRepo = journalRepository, + super(const HomeInitial()) { + on(_onLoadData); + on(_onRefresh); + } + + Future _onLoadData( + HomeLoadData event, + Emitter emit, + ) async { + emit(const HomeLoading()); + try { + final journals = await _journalRepo.getJournals( + page: 1, + pageSize: 10, + ); + + // 检查今日是否已写日记 + final today = DateTime.now(); + final hasTodayEntry = journals.any((j) => + j.date.year == today.year && + j.date.month == today.month && + j.date.day == today.day); + + // 推算最常用心情 + final moodCounts = {}; + for (final j in journals) { + moodCounts[j.mood] = (moodCounts[j.mood] ?? 0) + 1; + } + final topMood = moodCounts.entries + .fold?>(null, (a, b) => a == null || b.value > a.value ? b : a) + ?.key; + + // 推算连续天数 + final streakDays = _calculateStreak(journals); + + emit(HomeLoaded( + recentJournals: journals, + hasTodayEntry: hasTodayEntry, + topMood: topMood, + streakDays: streakDays, + )); + } catch (e) { + emit(const HomeLoaded()); // 空状态而非错误,离线友好 + } + } + + Future _onRefresh( + HomeRefresh event, + Emitter emit, + ) async { + add(const HomeLoadData()); + } + + /// 从日记列表推算连续写日记天数 + int _calculateStreak(List journals) { + if (journals.isEmpty) return 0; + + final dates = journals.map((j) => j.date).toSet(); + var streak = 0; + var checkDate = DateTime.now(); + + while (dates.contains(DateTime(checkDate.year, checkDate.month, checkDate.day))) { + streak++; + checkDate = checkDate.subtract(const Duration(days: 1)); + } + + return streak; + } +} diff --git a/app/lib/features/home/views/home_page.dart b/app/lib/features/home/views/home_page.dart index c21bc94..bb9ca3f 100644 --- a/app/lib/features/home/views/home_page.dart +++ b/app/lib/features/home/views/home_page.dart @@ -1,59 +1,96 @@ // 首页 — 日记流 + 心情概览 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/repositories/journal_repository.dart'; +import '../bloc/home_bloc.dart'; /// 首页 — 展示最近日记流和心情概览 class HomePage extends StatelessWidget { const HomePage({super.key}); + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => HomeBloc( + journalRepository: context.read(), + )..add(const HomeLoadData()), + child: const _HomeView(), + ); + } +} + +class _HomeView extends StatelessWidget { + const _HomeView(); + @override Widget build(BuildContext context) { final theme = Theme.of(context); final colorScheme = theme.colorScheme; - return Scaffold( - appBar: AppBar( - title: Text( - '暖记', - style: theme.textTheme.headlineSmall?.copyWith( - fontFamily: 'Caveat', - color: colorScheme.primary, + return BlocBuilder( + builder: (context, state) { + return Scaffold( + appBar: AppBar( + title: Text( + '暖记', + style: theme.textTheme.headlineSmall?.copyWith( + fontFamily: 'Caveat', + color: colorScheme.primary, + ), + ), + actions: [ + IconButton( + onPressed: () => context.go('/stickers'), + icon: const Icon(Icons.emoji_emotions_outlined), + tooltip: '贴纸库', + ), + IconButton( + onPressed: () => context.go('/templates'), + icon: const Icon(Icons.dashboard_customize_outlined), + tooltip: '模板', + ), + ], ), - ), - actions: [ - IconButton( - onPressed: () => context.go('/stickers'), - icon: const Icon(Icons.emoji_emotions_outlined), - tooltip: '贴纸库', - ), - IconButton( - onPressed: () => context.go('/templates'), - icon: const Icon(Icons.dashboard_customize_outlined), - tooltip: '模板', - ), - ], - ), - body: SingleChildScrollView( + body: state is HomeLoading + ? const Center(child: CircularProgressIndicator()) + : state is HomeLoaded + ? _buildContent(context, state) + : _buildContent(context, const HomeLoaded()), + ); + }, + ); + } + + Widget _buildContent(BuildContext context, HomeLoaded state) { + return RefreshIndicator( + onRefresh: () async { + context.read().add(const HomeRefresh()); + }, + child: SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // 心情快速选择卡片 - _QuickMoodCard(colorScheme: colorScheme), + _QuickMoodCard( + hasTodayEntry: state.hasTodayEntry, + topMood: state.topMood, + streakDays: state.streakDays, + ), const SizedBox(height: 20), - // 最近日记标题 + // 最近日记 Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( '最近日记', - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - ), + style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), ), TextButton( onPressed: () => context.go('/calendar'), @@ -63,8 +100,9 @@ class HomePage extends StatelessWidget { ), const SizedBox(height: 12), - // 日记流占位 — 待数据层集成后替换 - const _EmptyJournalState(), + state.recentJournals.isEmpty + ? const _EmptyJournalState() + : _JournalList(journals: state.recentJournals), ], ), ), @@ -74,13 +112,20 @@ class HomePage extends StatelessWidget { /// 心情快速选择卡片 class _QuickMoodCard extends StatelessWidget { - const _QuickMoodCard({required this.colorScheme}); + const _QuickMoodCard({ + required this.hasTodayEntry, + this.topMood, + this.streakDays = 0, + }); - final ColorScheme colorScheme; + final bool hasTodayEntry; + final Mood? topMood; + final int streakDays; @override Widget build(BuildContext context) { final theme = Theme.of(context); + final colorScheme = theme.colorScheme; final moods = [ ('😊', '开心', Mood.happy), ('😌', '平静', Mood.calm), @@ -91,25 +136,42 @@ class _QuickMoodCard extends StatelessWidget { return Card( elevation: 0, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(22), - ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(22)), color: colorScheme.primaryContainer.withValues(alpha: 0.3), child: Padding( padding: const EdgeInsets.all(20), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - '今天心情如何?', - style: theme.textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w600, - ), + Row( + children: [ + Text('今天心情如何?', style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600)), + const Spacer(), + if (streakDays > 0) + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: AppColors.tertiary.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(8), + ), + child: Text('🔥 连续 $streakDays 天', style: theme.textTheme.labelSmall), + ), + if (hasTodayEntry) + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: AppColors.secondary.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(8), + ), + child: Text('✅ 今日已写', style: theme.textTheme.labelSmall), + ), + ], ), const SizedBox(height: 16), Row( mainAxisAlignment: MainAxisAlignment.spaceAround, children: moods.map((mood) { + final isTop = topMood == mood.$3; return GestureDetector( onTap: () => context.go('/editor'), child: Column( @@ -119,20 +181,17 @@ class _QuickMoodCard extends StatelessWidget { height: 44, decoration: BoxDecoration( shape: BoxShape.circle, - color: (AppColors.moodColors[mood.$3.value] ?? - colorScheme.primary) - .withValues(alpha: 0.15), + color: (AppColors.moodColors[mood.$3.value] ?? colorScheme.primary) + .withValues(alpha: isTop ? 0.3 : 0.15), + border: isTop ? Border.all(color: AppColors.accent, width: 2) : null, ), alignment: Alignment.center, child: Text(mood.$1, style: const TextStyle(fontSize: 22)), ), const SizedBox(height: 4), - Text( - mood.$2, - style: theme.textTheme.labelSmall?.copyWith( - color: colorScheme.onSurface.withValues(alpha: 0.6), - ), - ), + Text(mood.$2, style: theme.textTheme.labelSmall?.copyWith( + color: colorScheme.onSurface.withValues(alpha: 0.6), + )), ], ), ); @@ -145,6 +204,74 @@ class _QuickMoodCard extends StatelessWidget { } } +/// 日记列表 +class _JournalList extends StatelessWidget { + const _JournalList({required this.journals}); + final List journals; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return Column( + children: journals.map((journal) { + 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: Row( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: moodColor.withValues(alpha: 0.2), + shape: BoxShape.circle, + ), + alignment: Alignment.center, + child: Text(_moodEmoji(journal.mood), style: const TextStyle(fontSize: 20)), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(journal.title, style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600), + maxLines: 1, overflow: TextOverflow.ellipsis), + const SizedBox(height: 4), + Text('${journal.date.month}月${journal.date.day}日', + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurface.withValues(alpha: 0.5)), + ), + ], + ), + ), + Icon(Icons.chevron_right, color: colorScheme.onSurface.withValues(alpha: 0.3)), + ], + ), + ), + ), + ); + }).toList(), + ); + } + + String _moodEmoji(Mood mood) => switch (mood) { + Mood.happy => '😊', Mood.calm => '😌', Mood.sad => '😢', + Mood.angry => '😠', Mood.thinking => '🤔', + }; +} + /// 空日记状态 class _EmptyJournalState extends StatelessWidget { const _EmptyJournalState(); @@ -159,18 +286,10 @@ class _EmptyJournalState extends StatelessWidget { padding: const EdgeInsets.symmetric(vertical: 48), child: Column( children: [ - Icon( - Icons.edit_note_rounded, - size: 64, - color: colorScheme.onSurface.withValues(alpha: 0.2), - ), + Icon(Icons.edit_note_rounded, size: 64, color: colorScheme.onSurface.withValues(alpha: 0.2)), const SizedBox(height: 16), - Text( - '开始你的第一篇手账日记吧!', - style: theme.textTheme.bodyLarge?.copyWith( - color: colorScheme.onSurface.withValues(alpha: 0.5), - ), - ), + Text('开始你的第一篇手账日记吧!', + style: theme.textTheme.bodyLarge?.copyWith(color: colorScheme.onSurface.withValues(alpha: 0.5))), const SizedBox(height: 24), FilledButton.icon( onPressed: () => context.go('/editor'), diff --git a/app/lib/features/profile/bloc/settings_bloc.dart b/app/lib/features/profile/bloc/settings_bloc.dart new file mode 100644 index 0000000..9c559e5 --- /dev/null +++ b/app/lib/features/profile/bloc/settings_bloc.dart @@ -0,0 +1,60 @@ +// 设置 BLoC — 主题切换 + 应用设置管理 + +import 'package:flutter/material.dart'; + +// ===== Events ===== + +sealed class SettingsEvent { + const SettingsEvent(); +} + +final class SettingsThemeChanged extends SettingsEvent { + final ThemeMode themeMode; + const SettingsThemeChanged(this.themeMode); +} + +final class SettingsLoad extends SettingsEvent { + const SettingsLoad(); +} + +// ===== State ===== + +class SettingsState { + final ThemeMode themeMode; + final bool isLoading; + + const SettingsState({ + this.themeMode = ThemeMode.system, + this.isLoading = false, + }); + + SettingsState copyWith({ThemeMode? themeMode, bool? isLoading}) => + SettingsState( + themeMode: themeMode ?? this.themeMode, + isLoading: isLoading ?? this.isLoading, + ); +} + +// ===== BLoC ===== + +class SettingsBloc extends ChangeNotifier { + SettingsState _state = const SettingsState(); + SettingsState get state => _state; + + /// 切换主题模式 + void changeTheme(ThemeMode mode) { + _state = _state.copyWith(themeMode: mode); + notifyListeners(); + // TODO: 持久化到 SharedPreferences/Isar + } + + /// 循环切换: system → light → dark → system + void cycleTheme() { + final next = switch (_state.themeMode) { + ThemeMode.system => ThemeMode.light, + ThemeMode.light => ThemeMode.dark, + ThemeMode.dark => ThemeMode.system, + }; + changeTheme(next); + } +} diff --git a/app/lib/features/teacher/views/teacher_page.dart b/app/lib/features/teacher/views/teacher_page.dart index fbfdb5a..f1e4ae4 100644 --- a/app/lib/features/teacher/views/teacher_page.dart +++ b/app/lib/features/teacher/views/teacher_page.dart @@ -4,6 +4,8 @@ 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/repositories/class_repository.dart'; +import 'package:nuanji_app/data/repositories/journal_repository.dart'; import '../../class_/bloc/class_bloc.dart'; /// 老师管理页面 — 教师专属功能入口 @@ -13,7 +15,10 @@ class TeacherPage extends StatelessWidget { @override Widget build(BuildContext context) { return BlocProvider( - create: (context) => ClassBloc()..add(const ClassLoadMyClasses()), + create: (context) => ClassBloc( + classRepository: context.read(), + journalRepository: context.read(), + )..add(const ClassLoadMyClasses()), child: const _TeacherView(), ); }