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:
@@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
139
app/lib/features/home/bloc/home_bloc.dart
Normal file
139
app/lib/features/home/bloc/home_bloc.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'),
|
||||||
|
|||||||
60
app/lib/features/profile/bloc/settings_bloc.dart
Normal file
60
app/lib/features/profile/bloc/settings_bloc.dart
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user