全局依赖注入: - 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
492 lines
15 KiB
Dart
492 lines
15 KiB
Dart
// 日历页面 — 月视图 + 日记列表
|
||
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||
import 'package:go_router/go_router.dart';
|
||
import 'package:nuanji_app/core/theme/app_colors.dart';
|
||
import 'package:nuanji_app/data/models/journal_entry.dart';
|
||
import 'package:nuanji_app/data/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 => '🤔',
|
||
};
|
||
}
|
||
}
|