Files
nj/app/lib/features/calendar/views/calendar_page.dart
iven 860e9e5d22 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
2026-06-01 10:32:20 +08:00

492 lines
15 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 日历页面 — 月视图 + 日记列表
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:nuanji_app/core/theme/app_colors.dart';
import 'package:nuanji_app/data/models/journal_entry.dart';
import 'package:nuanji_app/data/repositories/journal_repository.dart';
import '../bloc/calendar_bloc.dart';
/// 日历页面 — 月视图 + 选中日期的日记列表
class CalendarPage extends StatelessWidget {
const CalendarPage({super.key});
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => CalendarBloc(
journalRepository: context.read<JournalRepository>(),
)..add(CalendarMonthChanged(DateTime.now())),
child: const _CalendarView(),
);
}
}
class _CalendarView extends StatelessWidget {
const _CalendarView();
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return BlocBuilder<CalendarBloc, CalendarState>(
builder: (context, state) {
if (state is CalendarInitial) {
return const Center(child: CircularProgressIndicator());
}
if (state is CalendarError) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.error_outline, size: 48, color: colorScheme.error),
const SizedBox(height: 16),
Text(state.message, style: theme.textTheme.bodyLarge),
const SizedBox(height: 16),
FilledButton.tonal(
onPressed: () => context.read<CalendarBloc>()
.add(CalendarMonthChanged(DateTime.now())),
child: const Text('重试'),
),
],
),
);
}
if (state is! CalendarLoaded) return const SizedBox.shrink();
final loaded = state;
return Column(
children: [
// 月份导航
_MonthNavigator(
month: loaded.focusedMonth,
onPrevious: () {
final prev = DateTime(
loaded.focusedMonth.year,
loaded.focusedMonth.month - 1,
);
context.read<CalendarBloc>().add(CalendarMonthChanged(prev));
},
onNext: () {
final next = DateTime(
loaded.focusedMonth.year,
loaded.focusedMonth.month + 1,
);
context.read<CalendarBloc>().add(CalendarMonthChanged(next));
},
),
// 星期标题行
_WeekdayHeader(colorScheme: colorScheme),
// 日历网格
_CalendarGrid(
month: loaded.focusedMonth,
selectedDay: loaded.selectedDay,
journalsByDate: loaded.journalsByDate,
onDaySelected: (day) {
context.read<CalendarBloc>().add(CalendarDaySelected(day));
},
),
const Divider(height: 1),
// 选中日期的日记列表
Expanded(
child: loaded.selectedDayJournals.isEmpty
? _EmptyDayView(selectedDay: loaded.selectedDay)
: _DayJournalList(journals: loaded.selectedDayJournals),
),
],
);
},
);
}
}
/// 月份导航栏
class _MonthNavigator extends StatelessWidget {
const _MonthNavigator({
required this.month,
required this.onPrevious,
required this.onNext,
});
final DateTime month;
final VoidCallback onPrevious;
final VoidCallback onNext;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final monthName = _formatMonth(month);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
IconButton(
onPressed: onPrevious,
icon: const Icon(Icons.chevron_left),
tooltip: '上个月',
),
Text(
monthName,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
IconButton(
onPressed: onNext,
icon: const Icon(Icons.chevron_right),
tooltip: '下个月',
),
],
),
);
}
String _formatMonth(DateTime date) {
const months = [
'1月', '2月', '3月', '4月', '5月', '6月',
'7月', '8月', '9月', '10月', '11月', '12月',
];
return '${date.year}${months[date.month - 1]}';
}
}
/// 星期标题行
class _WeekdayHeader extends StatelessWidget {
const _WeekdayHeader({required this.colorScheme});
final ColorScheme colorScheme;
@override
Widget build(BuildContext context) {
const weekdays = ['', '', '', '', '', '', ''];
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: weekdays.map((day) {
return Expanded(
child: Center(
child: Text(
day,
style: TextStyle(
fontSize: 12,
color: colorScheme.onSurface.withValues(alpha: 0.6),
fontWeight: FontWeight.w500,
),
),
),
);
}).toList(),
),
);
}
}
/// 日历网格 — 6行7列
class _CalendarGrid extends StatelessWidget {
const _CalendarGrid({
required this.month,
required this.selectedDay,
required this.journalsByDate,
required this.onDaySelected,
});
final DateTime month;
final DateTime selectedDay;
final Map<DateTime, List<JournalEntry>> journalsByDate;
final ValueChanged<DateTime> onDaySelected;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final today = DateTime.now();
final days = _generateDays(month);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
children: days.map((week) {
return Row(
children: week.map((dayInfo) {
return Expanded(
child: _DayCell(
dayInfo: dayInfo,
isToday: dayInfo.date.year == today.year &&
dayInfo.date.month == today.month &&
dayInfo.date.day == today.day,
isSelected: dayInfo.date.year == selectedDay.year &&
dayInfo.date.month == selectedDay.month &&
dayInfo.date.day == selectedDay.day,
hasJournals: journalsByDate.containsKey(
DateTime(dayInfo.date.year, dayInfo.date.month, dayInfo.date.day),
),
colorScheme: colorScheme,
onTap: () => onDaySelected(dayInfo.date),
),
);
}).toList(),
);
}).toList(),
),
);
}
/// 生成当月日历数据(包含前后补齐)
List<List<_DayInfo>> _generateDays(DateTime month) {
final firstDay = DateTime(month.year, month.month, 1);
// 周一为第一天weekday 1=Mon...7=Sun
final startOffset = firstDay.weekday - 1;
final daysInMonth = DateTime(month.year, month.month + 1, 0).day;
final allDays = <_DayInfo>[];
// 前面的空白
for (var i = 0; i < startOffset; i++) {
allDays.add(_DayInfo(date: firstDay.subtract(Duration(days: startOffset - i)), isCurrentMonth: false));
}
// 当月日期
for (var d = 1; d <= daysInMonth; d++) {
allDays.add(_DayInfo(
date: DateTime(month.year, month.month, d),
isCurrentMonth: true,
));
}
// 后面的补齐到完整行
while (allDays.length % 7 != 0) {
final last = allDays.last.date;
allDays.add(_DayInfo(date: last.add(const Duration(days: 1)), isCurrentMonth: false));
}
// 分周
return List.generate(allDays.length ~/ 7, (i) => allDays.sublist(i * 7, (i + 1) * 7));
}
}
class _DayInfo {
const _DayInfo({required this.date, required this.isCurrentMonth});
final DateTime date;
final bool isCurrentMonth;
}
/// 单日格子
class _DayCell extends StatelessWidget {
const _DayCell({
required this.dayInfo,
required this.isToday,
required this.isSelected,
required this.hasJournals,
required this.colorScheme,
required this.onTap,
});
final _DayInfo dayInfo;
final bool isToday;
final bool isSelected;
final bool hasJournals;
final ColorScheme colorScheme;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
behavior: HitTestBehavior.opaque,
child: Container(
height: 44,
alignment: Alignment.center,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: isSelected
? colorScheme.primary
: isToday
? colorScheme.primaryContainer
: null,
),
alignment: Alignment.center,
child: Text(
'${dayInfo.date.day}',
style: TextStyle(
fontSize: 14,
fontWeight: isSelected || isToday ? FontWeight.bold : FontWeight.normal,
color: !dayInfo.isCurrentMonth
? colorScheme.onSurface.withValues(alpha: 0.3)
: isSelected
? colorScheme.onPrimary
: colorScheme.onSurface,
),
),
),
// 日记指示点
if (hasJournals)
Container(
width: 4,
height: 4,
margin: const EdgeInsets.only(top: 2),
decoration: BoxDecoration(
shape: BoxShape.circle,
color: isSelected
? colorScheme.onPrimary
: AppColors.accent,
),
),
],
),
),
);
}
}
/// 无日记的空状态
class _EmptyDayView extends StatelessWidget {
const _EmptyDayView({required this.selectedDay});
final DateTime selectedDay;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.edit_note_rounded,
size: 64,
color: theme.colorScheme.onSurface.withValues(alpha: 0.2),
),
const SizedBox(height: 16),
Text(
'${selectedDay.month}${selectedDay.day}',
style: theme.textTheme.titleMedium,
),
const SizedBox(height: 8),
Text(
'这一天还没有日记',
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurface.withValues(alpha: 0.5),
),
),
const SizedBox(height: 24),
FilledButton.tonal(
onPressed: () => context.go('/editor'),
child: const Text('写一篇'),
),
],
),
);
}
}
/// 日记列表
class _DayJournalList extends StatelessWidget {
const _DayJournalList({required this.journals});
final List<JournalEntry> journals;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: journals.length,
itemBuilder: (context, index) {
final journal = journals[index];
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,
),
if (journal.tags.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
journal.tags.take(3).join(' · '),
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withValues(alpha: 0.5),
),
),
],
],
),
),
Icon(
Icons.chevron_right,
color: colorScheme.onSurface.withValues(alpha: 0.3),
),
],
),
),
),
);
},
);
}
String _moodEmoji(Mood mood) {
return switch (mood) {
Mood.happy => '😊',
Mood.calm => '😌',
Mood.sad => '😢',
Mood.angry => '😠',
Mood.thinking => '🤔',
};
}
}