feat(app): BLoC 集成 Repository + SettingsBloc 主题切换

全局依赖注入:
- 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
This commit is contained in:
iven
2026-06-01 10:32:20 +08:00
parent 263ddf31a6
commit 860e9e5d22
9 changed files with 630 additions and 315 deletions

View File

@@ -1,9 +1,13 @@
// 暖记 App 根组件 — MaterialApp + BLoC Provider 注入 // 暖记 App 根组件 — MaterialApp + BLoC Provider 注入
// //
// 依赖注入结构: // 依赖注入结构:
// RepositoryProvider<AuthRepository> — 认证仓库(全局唯一) // MultiRepositoryProvider
// BlocProvider<AuthBloc> — 认证 BLoC全局唯一 // ApiClient
// └─ MaterialApp.router — 路由(使用 auth 状态守卫) // ├─ AuthRepository
// ├─ JournalRepository (RemoteJournalRepository)
// └─ ClassRepository
// └─ BlocProvider<AuthBloc>
// └─ MaterialApp.router
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.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 'core/routing/app_router.dart';
import 'data/remote/api_client.dart'; import 'data/remote/api_client.dart';
import 'data/repositories/auth_repository.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/auth/bloc/auth_bloc.dart';
import 'features/profile/bloc/settings_bloc.dart';
/// 暖记 App — 根组件 /// 暖记 App — 根组件
class NuanjiApp extends StatelessWidget { class NuanjiApp extends StatelessWidget {
@@ -24,19 +32,40 @@ class NuanjiApp extends StatelessWidget {
// 创建全局依赖App 生命周期内单例) // 创建全局依赖App 生命周期内单例)
final apiClient = ApiClient(); final apiClient = ApiClient();
final authRepository = AuthRepository(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); final authBloc = AuthBloc(authRepository: authRepository);
// 启动时检查认证状态 // 启动时检查认证状态
authBloc.add(const AppStarted()); 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( return MultiRepositoryProvider(
providers: [ providers: [
RepositoryProvider<ApiClient>.value(value: apiClient), RepositoryProvider<ApiClient>.value(value: apiClient),
RepositoryProvider<AuthRepository>.value(value: authRepository), RepositoryProvider<AuthRepository>.value(value: authRepository),
RepositoryProvider<JournalRepository>.value(value: journalRepository),
RepositoryProvider<ClassRepository>.value(value: classRepository),
], ],
child: BlocProvider<AuthBloc>.value( child: BlocProvider<AuthBloc>.value(
value: authBloc, 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 包装 /// App 视图 — MaterialApp.router 包装
class _AppView extends StatelessWidget { class _AppView extends StatelessWidget {
final GoRouter router; final GoRouter router;
final ThemeMode themeMode;
const _AppView({required this.router}); const _AppView({required this.router, this.themeMode = ThemeMode.system});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -55,7 +85,7 @@ class _AppView extends StatelessWidget {
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
theme: AppTheme.light(), theme: AppTheme.light(),
darkTheme: AppTheme.dark(), darkTheme: AppTheme.dark(),
themeMode: ThemeMode.system, themeMode: themeMode,
routerConfig: router, routerConfig: router,
); );
} }

View File

@@ -1,7 +1,8 @@
// 日历 BLoC — 管理日历视图状态和日记列表 // 日历 BLoC — 管理日历视图状态,通过 JournalRepository 加载数据
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:nuanji_app/data/models/journal_entry.dart'; import 'package:nuanji_app/data/models/journal_entry.dart';
import 'package:nuanji_app/data/repositories/journal_repository.dart';
// ===== Events ===== // ===== Events =====
@@ -21,18 +22,12 @@ final class CalendarDaySelected extends CalendarEvent {
const CalendarDaySelected(this.day); const CalendarDaySelected(this.day);
} }
/// 切换视图模式(月/周/时间轴) /// 切换视图模式
final class CalendarViewModeChanged extends CalendarEvent { final class CalendarViewModeChanged extends CalendarEvent {
final CalendarViewMode mode; final CalendarViewMode mode;
const CalendarViewModeChanged(this.mode); const CalendarViewModeChanged(this.mode);
} }
/// 加载某月的日记列表
final class CalendarLoadJournals extends CalendarEvent {
final DateTime month;
const CalendarLoadJournals(this.month);
}
// ===== State ===== // ===== State =====
/// 日历视图模式 /// 日历视图模式
@@ -43,29 +38,17 @@ sealed class CalendarState {
const CalendarState(); const CalendarState();
} }
/// 初始加载中
final class CalendarInitial extends CalendarState { final class CalendarInitial extends CalendarState {
const CalendarInitial(); const CalendarInitial();
} }
/// 日历已加载 — 包含当前月份、选中日期、日记列表 /// 日历已加载
final class CalendarLoaded extends CalendarState { final class CalendarLoaded extends CalendarState {
/// 当前显示的月份
final DateTime focusedMonth; final DateTime focusedMonth;
/// 选中的日期
final DateTime selectedDay; final DateTime selectedDay;
/// 当前月份所有日记(按日期索引)
final Map<DateTime, List<JournalEntry>> journalsByDate; final Map<DateTime, List<JournalEntry>> journalsByDate;
/// 当前选中日期的日记列表
final List<JournalEntry> selectedDayJournals; final List<JournalEntry> selectedDayJournals;
/// 视图模式
final CalendarViewMode viewMode; final CalendarViewMode viewMode;
/// 是否正在加载
final bool isLoading; final bool isLoading;
const CalendarLoaded({ const CalendarLoaded({
@@ -95,7 +78,6 @@ final class CalendarLoaded extends CalendarState {
); );
} }
/// 加载失败
final class CalendarError extends CalendarState { final class CalendarError extends CalendarState {
final String message; final String message;
const CalendarError(this.message); const CalendarError(this.message);
@@ -104,17 +86,20 @@ final class CalendarError extends CalendarState {
// ===== BLoC ===== // ===== BLoC =====
class CalendarBloc extends Bloc<CalendarEvent, CalendarState> { class CalendarBloc extends Bloc<CalendarEvent, CalendarState> {
CalendarBloc() : super(const CalendarInitial()) { final JournalRepository _journalRepo;
CalendarBloc({required JournalRepository journalRepository})
: _journalRepo = journalRepository,
super(const CalendarInitial()) {
on<CalendarMonthChanged>(_onMonthChanged); on<CalendarMonthChanged>(_onMonthChanged);
on<CalendarDaySelected>(_onDaySelected); on<CalendarDaySelected>(_onDaySelected);
on<CalendarViewModeChanged>(_onViewModeChanged); on<CalendarViewModeChanged>(_onViewModeChanged);
on<CalendarLoadJournals>(_onLoadJournals);
} }
void _onMonthChanged( Future<void> _onMonthChanged(
CalendarMonthChanged event, CalendarMonthChanged event,
Emitter<CalendarState> emit, Emitter<CalendarState> emit,
) { ) async {
final currentState = state is CalendarLoaded ? state as CalendarLoaded : null; final currentState = state is CalendarLoaded ? state as CalendarLoaded : null;
emit(CalendarLoaded( emit(CalendarLoaded(
@@ -123,9 +108,38 @@ class CalendarBloc extends Bloc<CalendarEvent, CalendarState> {
journalsByDate: currentState?.journalsByDate ?? {}, journalsByDate: currentState?.journalsByDate ?? {},
selectedDayJournals: [], selectedDayJournals: [],
viewMode: currentState?.viewMode ?? CalendarViewMode.month, 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 = <DateTime, List<JournalEntry>>{};
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( void _onDaySelected(
@@ -135,7 +149,6 @@ class CalendarBloc extends Bloc<CalendarEvent, CalendarState> {
if (state is! CalendarLoaded) return; if (state is! CalendarLoaded) return;
final current = state as CalendarLoaded; final current = state as CalendarLoaded;
// 查找选中日期的日记
final dayKey = DateTime(event.day.year, event.day.month, event.day.day); final dayKey = DateTime(event.day.year, event.day.month, event.day.day);
final dayJournals = current.journalsByDate[dayKey] ?? []; final dayJournals = current.journalsByDate[dayKey] ?? [];
@@ -150,26 +163,6 @@ class CalendarBloc extends Bloc<CalendarEvent, CalendarState> {
Emitter<CalendarState> emit, Emitter<CalendarState> emit,
) { ) {
if (state is! CalendarLoaded) return; if (state is! CalendarLoaded) return;
final current = state as CalendarLoaded; emit((state as CalendarLoaded).copyWith(viewMode: event.mode));
emit(current.copyWith(viewMode: event.mode));
}
Future<void> _onLoadJournals(
CalendarLoadJournals event,
Emitter<CalendarState> 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,
));
} }
} }

View File

@@ -5,6 +5,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:nuanji_app/core/theme/app_colors.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/journal_entry.dart';
import 'package:nuanji_app/data/repositories/journal_repository.dart';
import '../bloc/calendar_bloc.dart'; import '../bloc/calendar_bloc.dart';
/// 日历页面 — 月视图 + 选中日期的日记列表 /// 日历页面 — 月视图 + 选中日期的日记列表
@@ -14,8 +15,9 @@ class CalendarPage extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocProvider( return BlocProvider(
create: (context) => CalendarBloc() create: (context) => CalendarBloc(
..add(CalendarMonthChanged(DateTime.now())), journalRepository: context.read<JournalRepository>(),
)..add(CalendarMonthChanged(DateTime.now())),
child: const _CalendarView(), child: const _CalendarView(),
); );
} }

View File

@@ -1,8 +1,10 @@
// 班级 BLoC — 管理班级状态、成员、日记墙、主题布置和评语 // 班级 BLoC — 通过 ClassRepository 管理班级数据
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:nuanji_app/data/models/journal_entry.dart'; import 'package:nuanji_app/data/models/journal_entry.dart';
import 'package:nuanji_app/data/models/school_class.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 ===== // ===== Events =====
@@ -10,43 +12,41 @@ sealed class ClassEvent {
const ClassEvent(); const ClassEvent();
} }
/// 加载我的班级列表
final class ClassLoadMyClasses extends ClassEvent { final class ClassLoadMyClasses extends ClassEvent {
const ClassLoadMyClasses(); const ClassLoadMyClasses();
} }
/// 选择当前班级
final class ClassSelected extends ClassEvent { final class ClassSelected extends ClassEvent {
final String classId; final String classId;
const ClassSelected(this.classId); const ClassSelected(this.classId);
} }
/// 加载班级成员列表
final class ClassLoadMembers extends ClassEvent { final class ClassLoadMembers extends ClassEvent {
final String classId; final String classId;
const ClassLoadMembers(this.classId); const ClassLoadMembers(this.classId);
} }
/// 加载班级日记墙(已分享到班级的日记)
final class ClassLoadDiaryWall extends ClassEvent { final class ClassLoadDiaryWall extends ClassEvent {
final String classId; final String classId;
const ClassLoadDiaryWall(this.classId); const ClassLoadDiaryWall(this.classId);
} }
/// 加载主题布置列表
final class ClassLoadTopics extends ClassEvent { final class ClassLoadTopics extends ClassEvent {
final String classId; final String classId;
const ClassLoadTopics(this.classId); const ClassLoadTopics(this.classId);
} }
/// 创建班级(老师) final class ClassLoadComments extends ClassEvent {
final String journalId;
const ClassLoadComments(this.journalId);
}
final class ClassCreate extends ClassEvent { final class ClassCreate extends ClassEvent {
final String name; final String name;
final String? schoolName; final String? schoolName;
const ClassCreate({required this.name, this.schoolName}); const ClassCreate({required this.name, this.schoolName});
} }
/// 布置主题(老师)
final class TopicAssign extends ClassEvent { final class TopicAssign extends ClassEvent {
final String classId; final String classId;
final String title; final String title;
@@ -60,30 +60,22 @@ final class TopicAssign extends ClassEvent {
}); });
} }
/// 加载日记评语 final class ClassJoin extends ClassEvent {
final class ClassLoadComments extends ClassEvent { final String classCode;
final String journalId; final String? nickname;
const ClassLoadComments(this.journalId); const ClassJoin({required this.classCode, this.nickname});
} }
// ===== State ===== // ===== State =====
/// 班级成员模型
class ClassMember { class ClassMember {
final String userId; final String userId;
final String role; final String role;
final String? nickname; final String? nickname;
final DateTime joinedAt; 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 { class TopicAssignment {
final String id; final String id;
final String classId; final String classId;
@@ -92,71 +84,38 @@ class TopicAssignment {
final String? description; final String? description;
final DateTime? dueDate; final DateTime? dueDate;
final bool isActive; 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 { class Comment {
final String id; final String id;
final String journalId; final String journalId;
final String authorId; final String authorId;
final String content; final String content;
final DateTime createdAt; 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 { sealed class ClassState {
const ClassState(); const ClassState();
} }
/// 初始状态
final class ClassInitial extends ClassState { final class ClassInitial extends ClassState {
const ClassInitial(); const ClassInitial();
} }
/// 加载中
final class ClassLoading extends ClassState { final class ClassLoading extends ClassState {
const ClassLoading(); const ClassLoading();
} }
/// 班级列表已加载
final class ClassListLoaded extends ClassState { final class ClassListLoaded extends ClassState {
final List<SchoolClass> classes; final List<SchoolClass> classes;
final bool isLoading; final bool isLoading;
const ClassListLoaded({this.classes = const [], this.isLoading = false});
const ClassListLoaded({ ClassListLoaded copyWith({List<SchoolClass>? classes, bool? isLoading}) =>
this.classes = const [], ClassListLoaded(classes: classes ?? this.classes, isLoading: isLoading ?? this.isLoading);
this.isLoading = false,
});
ClassListLoaded copyWith({
List<SchoolClass>? classes,
bool? isLoading,
}) =>
ClassListLoaded(
classes: classes ?? this.classes,
isLoading: isLoading ?? this.isLoading,
);
} }
/// 班级详情已加载(日记墙 / 成员 / 主题 / 评语)
final class ClassDetailLoaded extends ClassState { final class ClassDetailLoaded extends ClassState {
final SchoolClass classInfo; final SchoolClass classInfo;
final List<ClassMember> members; final List<ClassMember> members;
@@ -197,13 +156,10 @@ final class ClassDetailLoaded extends ClassState {
comments: comments ?? this.comments, comments: comments ?? this.comments,
isLoadingWall: isLoadingWall ?? this.isLoadingWall, isLoadingWall: isLoadingWall ?? this.isLoadingWall,
isLoadingMembers: isLoadingMembers ?? this.isLoadingMembers, isLoadingMembers: isLoadingMembers ?? this.isLoadingMembers,
selectedJournalId: clearSelectedJournal selectedJournalId: clearSelectedJournal ? null : (selectedJournalId ?? this.selectedJournalId),
? null
: (selectedJournalId ?? this.selectedJournalId),
); );
} }
/// 错误状态
final class ClassError extends ClassState { final class ClassError extends ClassState {
final String message; final String message;
const ClassError(this.message); const ClassError(this.message);
@@ -212,15 +168,24 @@ final class ClassError extends ClassState {
// ===== BLoC ===== // ===== BLoC =====
class ClassBloc extends Bloc<ClassEvent, ClassState> { class ClassBloc extends Bloc<ClassEvent, ClassState> {
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<ClassLoadMyClasses>(_onLoadMyClasses); on<ClassLoadMyClasses>(_onLoadMyClasses);
on<ClassSelected>(_onClassSelected); on<ClassSelected>(_onClassSelected);
on<ClassLoadMembers>(_onLoadMembers); on<ClassLoadMembers>(_onLoadMembers);
on<ClassLoadDiaryWall>(_onLoadDiaryWall); on<ClassLoadDiaryWall>(_onLoadDiaryWall);
on<ClassLoadTopics>(_onLoadTopics); on<ClassLoadTopics>(_onLoadTopics);
on<ClassLoadComments>(_onLoadComments);
on<ClassCreate>(_onCreateClass); on<ClassCreate>(_onCreateClass);
on<TopicAssign>(_onTopicAssign); on<TopicAssign>(_onTopicAssign);
on<ClassLoadComments>(_onLoadComments); on<ClassJoin>(_onJoinClass);
} }
Future<void> _onLoadMyClasses( Future<void> _onLoadMyClasses(
@@ -228,46 +193,27 @@ class ClassBloc extends Bloc<ClassEvent, ClassState> {
Emitter<ClassState> emit, Emitter<ClassState> emit,
) async { ) async {
emit(const ClassListLoaded(isLoading: true)); emit(const ClassListLoaded(isLoading: true));
try {
// Phase 1: 占位数据,待 API 集成 final classes = await _classRepo.getMyClasses();
await Future.delayed(const Duration(milliseconds: 300)); emit(ClassListLoaded(classes: classes));
final now = DateTime.now(); } catch (e) {
emit(ClassListLoaded(classes: const []));
emit(ClassListLoaded(classes: [ }
SchoolClass(
id: 'class-1',
name: '三年二班',
schoolName: '阳光小学',
teacherId: 'teacher-1',
classCode: 'a1b2c3',
memberCount: 28,
createdAt: now,
updatedAt: now,
),
]));
} }
Future<void> _onClassSelected( Future<void> _onClassSelected(
ClassSelected event, ClassSelected event,
Emitter<ClassState> emit, Emitter<ClassState> emit,
) async { ) async {
final now = DateTime.now(); try {
final classInfo = SchoolClass( final classInfo = await _classRepo.getClass(event.classId);
id: event.classId,
name: '三年二班',
schoolName: '阳光小学',
teacherId: 'teacher-1',
classCode: 'a1b2c3',
memberCount: 28,
createdAt: now,
updatedAt: now,
);
emit(ClassDetailLoaded(classInfo: classInfo)); emit(ClassDetailLoaded(classInfo: classInfo));
add(ClassLoadDiaryWall(event.classId)); add(ClassLoadDiaryWall(event.classId));
add(ClassLoadMembers(event.classId)); add(ClassLoadMembers(event.classId));
add(ClassLoadTopics(event.classId)); add(ClassLoadTopics(event.classId));
} catch (e) {
emit(ClassError('加载班级失败: $e'));
}
} }
Future<void> _onLoadMembers( Future<void> _onLoadMembers(
@@ -278,20 +224,20 @@ class ClassBloc extends Bloc<ClassEvent, ClassState> {
final current = state as ClassDetailLoaded; final current = state as ClassDetailLoaded;
emit(current.copyWith(isLoadingMembers: true)); emit(current.copyWith(isLoadingMembers: true));
await Future.delayed(const Duration(milliseconds: 200)); try {
final now = DateTime.now(); final dtos = await _classRepo.getMembers(event.classId);
final members = dtos
final members = List.generate( .map((d) => ClassMember(
28, userId: d.userId,
(i) => ClassMember( role: d.role,
userId: 'user-$i', nickname: d.nickname,
role: i == 0 ? 'teacher' : 'student', joinedAt: d.joinedAt,
nickname: i == 0 ? '王老师' : '同学$i', ))
joinedAt: now, .toList();
),
);
emit(current.copyWith(members: members, isLoadingMembers: false)); emit(current.copyWith(members: members, isLoadingMembers: false));
} catch (_) {
emit(current.copyWith(isLoadingMembers: false));
}
} }
Future<void> _onLoadDiaryWall( Future<void> _onLoadDiaryWall(
@@ -302,26 +248,17 @@ class ClassBloc extends Bloc<ClassEvent, ClassState> {
final current = state as ClassDetailLoaded; final current = state as ClassDetailLoaded;
emit(current.copyWith(isLoadingWall: true)); emit(current.copyWith(isLoadingWall: true));
await Future.delayed(const Duration(milliseconds: 200)); try {
final now = DateTime.now(); // 加载属于该班级的公开日记
final titles = ['快乐的周末', '春天来了', '我的小猫', '数学课', '好朋友', '下雨天']; final journals = await _journalRepo.getJournals();
final classJournals = journals
.where((j) => j.classId == event.classId && j.sharedToClass)
.toList();
final diaries = List.generate( emit(current.copyWith(diaryWall: classJournals, isLoadingWall: false));
6, } catch (_) {
(i) => JournalEntry( emit(current.copyWith(isLoadingWall: false));
id: 'diary-$i', }
authorId: 'user-${i + 1}',
classId: event.classId,
title: titles[i],
date: now.subtract(Duration(days: i)),
mood: Mood.values[i % Mood.values.length],
tags: const ['日常', '心情'],
createdAt: now,
updatedAt: now,
),
);
emit(current.copyWith(diaryWall: diaries, isLoadingWall: false));
} }
Future<void> _onLoadTopics( Future<void> _onLoadTopics(
@@ -331,59 +268,23 @@ class ClassBloc extends Bloc<ClassEvent, ClassState> {
if (state is! ClassDetailLoaded) return; if (state is! ClassDetailLoaded) return;
final current = state as ClassDetailLoaded; final current = state as ClassDetailLoaded;
final topics = [ try {
TopicAssignment( final dtos = await _classRepo.getTopics(event.classId);
id: 'topic-1', final topics = dtos
classId: event.classId, .map((d) => TopicAssignment(
teacherId: 'teacher-1', id: d.id,
title: '我的周末', classId: d.classId,
description: '写一篇关于你周末生活的日记', teacherId: d.teacherId,
dueDate: DateTime.now().add(const Duration(days: 3)), title: d.title,
), description: d.description,
TopicAssignment( dueDate: d.dueDate,
id: 'topic-2', isActive: d.isActive,
classId: event.classId, ))
teacherId: 'teacher-1', .toList();
title: '最开心的一天',
description: '回忆一下让你最开心的一天',
),
];
emit(current.copyWith(topics: topics)); emit(current.copyWith(topics: topics));
} catch (_) {
// 静默失败,保留空列表
} }
Future<void> _onCreateClass(
ClassCreate event,
Emitter<ClassState> emit,
) async {
final newClass = SchoolClass.create(
name: event.name,
schoolName: event.schoolName ?? '',
teacherId: 'current-user',
classCode: 'x1y2z3',
);
if (state is ClassListLoaded) {
final current = state as ClassListLoaded;
emit(current.copyWith(classes: [...current.classes, newClass]));
}
}
Future<void> _onTopicAssign(
TopicAssign event,
Emitter<ClassState> emit,
) async {
if (state is! ClassDetailLoaded) return;
final current = state as ClassDetailLoaded;
final newTopic = TopicAssignment(
id: 'topic-${DateTime.now().millisecondsSinceEpoch}',
classId: event.classId,
teacherId: 'current-user',
title: event.title,
description: event.description,
dueDate: event.dueDate,
);
emit(current.copyWith(topics: [newTopic, ...current.topics]));
} }
Future<void> _onLoadComments( Future<void> _onLoadComments(
@@ -393,18 +294,79 @@ class ClassBloc extends Bloc<ClassEvent, ClassState> {
if (state is! ClassDetailLoaded) return; if (state is! ClassDetailLoaded) return;
final current = state as ClassDetailLoaded; final current = state as ClassDetailLoaded;
final comments = [ try {
Comment( final dtos = await _classRepo.getComments(event.journalId);
id: 'comment-1', final comments = dtos
journalId: event.journalId, .map((d) => Comment(
authorId: 'teacher-1', id: d.id,
content: '写得很好,继续保持!', journalId: d.journalId,
createdAt: DateTime.now(), authorId: d.authorId,
), content: d.content,
]; createdAt: d.createdAt,
emit(current.copyWith( ))
comments: comments, .toList();
selectedJournalId: event.journalId, emit(current.copyWith(comments: comments, selectedJournalId: event.journalId));
)); } catch (_) {
emit(current.copyWith(selectedJournalId: event.journalId));
}
}
Future<void> _onCreateClass(
ClassCreate event,
Emitter<ClassState> 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<void> _onTopicAssign(
TopicAssign event,
Emitter<ClassState> 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<void> _onJoinClass(
ClassJoin event,
Emitter<ClassState> emit,
) async {
try {
await _classRepo.joinClass(event.classCode, nickname: event.nickname);
// 加入成功后刷新列表
add(const ClassLoadMyClasses());
} catch (e) {
emit(ClassError('加入班级失败: $e'));
}
} }
} }

View File

@@ -6,6 +6,8 @@ import 'package:go_router/go_router.dart';
import 'package:nuanji_app/core/theme/app_colors.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/journal_entry.dart';
import 'package:nuanji_app/data/models/school_class.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'; import '../bloc/class_bloc.dart';
/// 班级主页 — 日记墙 + 班级信息 /// 班级主页 — 日记墙 + 班级信息
@@ -15,7 +17,10 @@ class ClassPage extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocProvider( return BlocProvider(
create: (context) => ClassBloc()..add(const ClassLoadMyClasses()), create: (context) => ClassBloc(
classRepository: context.read<ClassRepository>(),
journalRepository: context.read<JournalRepository>(),
)..add(const ClassLoadMyClasses()),
child: const _ClassView(), child: const _ClassView(),
); );
} }

View File

@@ -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<JournalEntry> 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<HomeEvent, HomeState> {
final JournalRepository _journalRepo;
HomeBloc({required JournalRepository journalRepository})
: _journalRepo = journalRepository,
super(const HomeInitial()) {
on<HomeLoadData>(_onLoadData);
on<HomeRefresh>(_onRefresh);
}
Future<void> _onLoadData(
HomeLoadData event,
Emitter<HomeState> 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 = <Mood, int>{};
for (final j in journals) {
moodCounts[j.mood] = (moodCounts[j.mood] ?? 0) + 1;
}
final topMood = moodCounts.entries
.fold<MapEntry<Mood, int>?>(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<void> _onRefresh(
HomeRefresh event,
Emitter<HomeState> emit,
) async {
add(const HomeLoadData());
}
/// 从日记列表推算连续写日记天数
int _calculateStreak(List<JournalEntry> 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;
}
}

View File

@@ -1,19 +1,38 @@
// 首页 — 日记流 + 心情概览 // 首页 — 日记流 + 心情概览
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:go_router/go_router.dart';
import 'package:nuanji_app/core/theme/app_colors.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/journal_entry.dart';
import 'package:nuanji_app/data/repositories/journal_repository.dart';
import '../bloc/home_bloc.dart';
/// 首页 — 展示最近日记流和心情概览 /// 首页 — 展示最近日记流和心情概览
class HomePage extends StatelessWidget { class HomePage extends StatelessWidget {
const HomePage({super.key}); const HomePage({super.key});
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => HomeBloc(
journalRepository: context.read<JournalRepository>(),
)..add(const HomeLoadData()),
child: const _HomeView(),
);
}
}
class _HomeView extends StatelessWidget {
const _HomeView();
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final colorScheme = theme.colorScheme; final colorScheme = theme.colorScheme;
return BlocBuilder<HomeBloc, HomeState>(
builder: (context, state) {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text( title: Text(
@@ -36,24 +55,42 @@ class HomePage extends StatelessWidget {
), ),
], ],
), ),
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<HomeBloc>().add(const HomeRefresh());
},
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// 心情快速选择卡片 // 心情快速选择卡片
_QuickMoodCard(colorScheme: colorScheme), _QuickMoodCard(
hasTodayEntry: state.hasTodayEntry,
topMood: state.topMood,
streakDays: state.streakDays,
),
const SizedBox(height: 20), const SizedBox(height: 20),
// 最近日记标题 // 最近日记
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Text( Text(
'最近日记', '最近日记',
style: theme.textTheme.titleMedium?.copyWith( style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
fontWeight: FontWeight.bold,
),
), ),
TextButton( TextButton(
onPressed: () => context.go('/calendar'), onPressed: () => context.go('/calendar'),
@@ -63,8 +100,9 @@ class HomePage extends StatelessWidget {
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
// 日记流占位 — 待数据层集成后替换 state.recentJournals.isEmpty
const _EmptyJournalState(), ? const _EmptyJournalState()
: _JournalList(journals: state.recentJournals),
], ],
), ),
), ),
@@ -74,13 +112,20 @@ class HomePage extends StatelessWidget {
/// 心情快速选择卡片 /// 心情快速选择卡片
class _QuickMoodCard 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final moods = [ final moods = [
('😊', '开心', Mood.happy), ('😊', '开心', Mood.happy),
('😌', '平静', Mood.calm), ('😌', '平静', Mood.calm),
@@ -91,25 +136,42 @@ class _QuickMoodCard extends StatelessWidget {
return Card( return Card(
elevation: 0, elevation: 0,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(22)),
borderRadius: BorderRadius.circular(22),
),
color: colorScheme.primaryContainer.withValues(alpha: 0.3), color: colorScheme.primaryContainer.withValues(alpha: 0.3),
child: Padding( child: Padding(
padding: const EdgeInsets.all(20), padding: const EdgeInsets.all(20),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Row(
'今天心情如何?', children: [
style: theme.textTheme.titleSmall?.copyWith( Text('今天心情如何?', style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600)),
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), const SizedBox(height: 16),
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceAround, mainAxisAlignment: MainAxisAlignment.spaceAround,
children: moods.map((mood) { children: moods.map((mood) {
final isTop = topMood == mood.$3;
return GestureDetector( return GestureDetector(
onTap: () => context.go('/editor'), onTap: () => context.go('/editor'),
child: Column( child: Column(
@@ -119,20 +181,17 @@ class _QuickMoodCard extends StatelessWidget {
height: 44, height: 44,
decoration: BoxDecoration( decoration: BoxDecoration(
shape: BoxShape.circle, shape: BoxShape.circle,
color: (AppColors.moodColors[mood.$3.value] ?? color: (AppColors.moodColors[mood.$3.value] ?? colorScheme.primary)
colorScheme.primary) .withValues(alpha: isTop ? 0.3 : 0.15),
.withValues(alpha: 0.15), border: isTop ? Border.all(color: AppColors.accent, width: 2) : null,
), ),
alignment: Alignment.center, alignment: Alignment.center,
child: Text(mood.$1, style: const TextStyle(fontSize: 22)), child: Text(mood.$1, style: const TextStyle(fontSize: 22)),
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(mood.$2, style: theme.textTheme.labelSmall?.copyWith(
mood.$2,
style: theme.textTheme.labelSmall?.copyWith(
color: colorScheme.onSurface.withValues(alpha: 0.6), 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<JournalEntry> 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 { class _EmptyJournalState extends StatelessWidget {
const _EmptyJournalState(); const _EmptyJournalState();
@@ -159,18 +286,10 @@ class _EmptyJournalState extends StatelessWidget {
padding: const EdgeInsets.symmetric(vertical: 48), padding: const EdgeInsets.symmetric(vertical: 48),
child: Column( child: Column(
children: [ children: [
Icon( Icon(Icons.edit_note_rounded, size: 64, color: colorScheme.onSurface.withValues(alpha: 0.2)),
Icons.edit_note_rounded,
size: 64,
color: colorScheme.onSurface.withValues(alpha: 0.2),
),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text('开始你的第一篇手账日记吧!',
'开始你的第一篇手账日记吧!', style: theme.textTheme.bodyLarge?.copyWith(color: colorScheme.onSurface.withValues(alpha: 0.5))),
style: theme.textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurface.withValues(alpha: 0.5),
),
),
const SizedBox(height: 24), const SizedBox(height: 24),
FilledButton.icon( FilledButton.icon(
onPressed: () => context.go('/editor'), onPressed: () => context.go('/editor'),

View File

@@ -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);
}
}

View File

@@ -4,6 +4,8 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:nuanji_app/core/theme/app_colors.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'; import '../../class_/bloc/class_bloc.dart';
/// 老师管理页面 — 教师专属功能入口 /// 老师管理页面 — 教师专属功能入口
@@ -13,7 +15,10 @@ class TeacherPage extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocProvider( return BlocProvider(
create: (context) => ClassBloc()..add(const ClassLoadMyClasses()), create: (context) => ClassBloc(
classRepository: context.read<ClassRepository>(),
journalRepository: context.read<JournalRepository>(),
)..add(const ClassLoadMyClasses()),
child: const _TeacherView(), child: const _TeacherView(),
); );
} }