Phase 0 — 共享组件: - EmptyStateWidget: 统一空状态 (icon + title + subtitle + CTA) - ErrorStateWidget: 统一错误状态 (message + retry) - SkeletonBox + SkeletonList: 统一骨架屏加载 (shimmer 动画) Phase 1 — Bug 修复: - 班级评论按 journalId 过滤,避免显示在错误日记卡片下 - moodCellColors key 修正: love/tired → angry/thinking - 日历非 CalendarLoaded 状态改为加载指示器 (不再 SizedBox.shrink) - 贴纸数统计改为 '--' 占位 (之前错误显示日记总数)
982 lines
32 KiB
Dart
982 lines
32 KiB
Dart
// 日历页面 — 月视图(心情色彩) + 周视图 + 时间轴
|
||
// 对齐 Open Design 原型稿: 日历格子用心情颜色填充背景 + 月/周/时间轴切换
|
||
|
||
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/core/theme/app_radius.dart';
|
||
import 'package:nuanji_app/core/utils/mood_utils.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 Center(child: CircularProgressIndicator());
|
||
}
|
||
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));
|
||
},
|
||
),
|
||
|
||
// 视图模式切换
|
||
Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||
child: SegmentedButton<CalendarViewMode>(
|
||
segments: const [
|
||
ButtonSegment(value: CalendarViewMode.month, label: Text('月')),
|
||
ButtonSegment(value: CalendarViewMode.week, label: Text('周')),
|
||
ButtonSegment(value: CalendarViewMode.timeline, label: Text('时间轴')),
|
||
],
|
||
selected: {loaded.viewMode},
|
||
onSelectionChanged: (modes) {
|
||
context.read<CalendarBloc>()
|
||
.add(CalendarViewModeChanged(modes.first));
|
||
},
|
||
style: ButtonStyle(
|
||
visualDensity: VisualDensity.compact,
|
||
textStyle: WidgetStatePropertyAll(theme.textTheme.labelSmall),
|
||
),
|
||
),
|
||
),
|
||
|
||
// 根据视图模式切换内容
|
||
switch (loaded.viewMode) {
|
||
CalendarViewMode.month => _MonthView(loaded: loaded),
|
||
CalendarViewMode.week => _WeekView(loaded: loaded),
|
||
CalendarViewMode.timeline => _TimelineView(loaded: loaded),
|
||
},
|
||
],
|
||
);
|
||
},
|
||
);
|
||
}
|
||
}
|
||
|
||
// ===== 月视图 =====
|
||
|
||
class _MonthView extends StatelessWidget {
|
||
const _MonthView({required this.loaded});
|
||
|
||
final CalendarLoaded loaded;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Expanded(
|
||
child: Column(
|
||
children: [
|
||
// 本月心情概览柱状图
|
||
_MoodSummaryChart(journalsByDate: loaded.journalsByDate),
|
||
const SizedBox(height: 8),
|
||
|
||
// 星期标题行
|
||
_WeekdayHeader(colorScheme: Theme.of(context).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),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 本月心情概览 — 5 柱状图
|
||
class _MoodSummaryChart extends StatelessWidget {
|
||
const _MoodSummaryChart({required this.journalsByDate});
|
||
|
||
final Map<DateTime, List<JournalEntry>> journalsByDate;
|
||
|
||
static const _moodConfig = [
|
||
(Mood.happy, '开心', AppColors.secondary),
|
||
(Mood.calm, '平静', AppColors.tertiary),
|
||
(Mood.sad, '难过', Color(0xFF5B7DB1)),
|
||
(Mood.angry, '生气', AppColors.accent),
|
||
(Mood.thinking, '思考', Color(0xFF8B7E74)),
|
||
];
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
|
||
// 统计每种心情的数量
|
||
final counts = <Mood, int>{};
|
||
for (final entry in journalsByDate.entries) {
|
||
for (final journal in entry.value) {
|
||
counts[journal.mood] = (counts[journal.mood] ?? 0) + 1;
|
||
}
|
||
}
|
||
|
||
final maxCount = counts.values.fold(0, (a, b) => a > b ? a : b).toDouble();
|
||
final barMaxHeight = 60.0;
|
||
|
||
return Container(
|
||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
||
padding: const EdgeInsets.all(20),
|
||
decoration: BoxDecoration(
|
||
color: theme.colorScheme.surface,
|
||
borderRadius: BorderRadius.circular(16),
|
||
boxShadow: [
|
||
BoxShadow(
|
||
color: theme.colorScheme.shadow.withValues(alpha: 0.05),
|
||
offset: const Offset(0, 2),
|
||
blurRadius: 8,
|
||
),
|
||
],
|
||
),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
'本月心情概览',
|
||
style: TextStyle(
|
||
fontSize: 15,
|
||
fontWeight: FontWeight.w600,
|
||
color: theme.colorScheme.onSurface,
|
||
),
|
||
),
|
||
const SizedBox(height: 16),
|
||
Row(
|
||
crossAxisAlignment: CrossAxisAlignment.end,
|
||
children: _moodConfig.map((config) {
|
||
final count = counts[config.$1] ?? 0;
|
||
final barHeight = maxCount > 0 && count > 0
|
||
? (count / maxCount * barMaxHeight).clamp(4.0, barMaxHeight)
|
||
: 4.0;
|
||
|
||
return Expanded(
|
||
child: Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Container(
|
||
height: barHeight,
|
||
decoration: BoxDecoration(
|
||
color: config.$3,
|
||
borderRadius: const BorderRadius.vertical(
|
||
top: Radius.circular(6),
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(height: 6),
|
||
Text(
|
||
config.$2,
|
||
style: TextStyle(
|
||
fontSize: 11,
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}).toList(),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
// ===== 周视图 =====
|
||
|
||
class _WeekView extends StatelessWidget {
|
||
const _WeekView({required this.loaded});
|
||
|
||
final CalendarLoaded loaded;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
final colorScheme = theme.colorScheme;
|
||
|
||
// 计算选中日期所在周
|
||
final selected = loaded.selectedDay;
|
||
final startOfWeek = selected.subtract(Duration(days: selected.weekday - 1));
|
||
|
||
return Expanded(
|
||
child: ListView(
|
||
padding: const EdgeInsets.all(16),
|
||
children: [
|
||
// 7 天条目
|
||
...List.generate(7, (i) {
|
||
final day = startOfWeek.add(Duration(days: i));
|
||
final dayKey = DateTime(day.year, day.month, day.day);
|
||
final journals = loaded.journalsByDate[dayKey] ?? [];
|
||
final isToday = _isToday(day);
|
||
final isSelected = _isSameDay(day, loaded.selectedDay);
|
||
final hasEntry = journals.isNotEmpty;
|
||
|
||
return Padding(
|
||
padding: const EdgeInsets.only(bottom: 8),
|
||
child: InkWell(
|
||
onTap: () {
|
||
context.read<CalendarBloc>().add(CalendarDaySelected(day));
|
||
},
|
||
borderRadius: AppRadius.mdBorder,
|
||
child: Container(
|
||
padding: const EdgeInsets.all(16),
|
||
decoration: BoxDecoration(
|
||
color: isSelected
|
||
? colorScheme.primaryContainer
|
||
: hasEntry
|
||
? _getMoodBgColor(journals.first.mood.value)
|
||
: colorScheme.surface,
|
||
borderRadius: AppRadius.mdBorder,
|
||
border: Border.all(
|
||
color: isToday
|
||
? colorScheme.primary
|
||
: colorScheme.outlineVariant,
|
||
width: isToday ? 2 : 1,
|
||
),
|
||
),
|
||
child: Row(
|
||
children: [
|
||
// 日期
|
||
SizedBox(
|
||
width: 48,
|
||
child: Column(
|
||
children: [
|
||
Text(
|
||
_weekdayName(day.weekday),
|
||
style: theme.textTheme.labelSmall?.copyWith(
|
||
color: colorScheme.onSurfaceVariant,
|
||
),
|
||
),
|
||
Text(
|
||
'${day.day}',
|
||
style: theme.textTheme.titleLarge?.copyWith(
|
||
fontWeight: isToday ? FontWeight.bold : FontWeight.normal,
|
||
color: isToday ? colorScheme.primary : null,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(width: 16),
|
||
// 内容
|
||
Expanded(
|
||
child: hasEntry
|
||
? Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
journals.first.title,
|
||
style: theme.textTheme.titleSmall?.copyWith(
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
if (journals.length > 1)
|
||
Text(
|
||
'还有 ${journals.length - 1} 篇日记',
|
||
style: theme.textTheme.bodySmall?.copyWith(
|
||
color: colorScheme.onSurfaceVariant,
|
||
),
|
||
),
|
||
],
|
||
)
|
||
: Text(
|
||
'无日记',
|
||
style: theme.textTheme.bodySmall?.copyWith(
|
||
color: colorScheme.onSurface.withValues(alpha: 0.4),
|
||
),
|
||
),
|
||
),
|
||
// 心情 emoji
|
||
if (hasEntry)
|
||
Text(
|
||
moodToEmoji(journals.first.mood),
|
||
style: const TextStyle(fontSize: 24),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
// ===== 时间轴视图 =====
|
||
|
||
class _TimelineView extends StatelessWidget {
|
||
const _TimelineView({required this.loaded});
|
||
|
||
final CalendarLoaded loaded;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
final colorScheme = theme.colorScheme;
|
||
|
||
// 获取当月所有日记,按日期排序
|
||
final allJournals = loaded.journalsByDate.entries
|
||
.expand((e) => e.value.map((j) => MapEntry(e.key, j)))
|
||
.toList()
|
||
..sort((a, b) => b.key.compareTo(a.key));
|
||
|
||
if (allJournals.isEmpty) {
|
||
return Center(
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Icon(Icons.timeline_outlined, size: 48,
|
||
color: colorScheme.onSurface.withValues(alpha: 0.2)),
|
||
const SizedBox(height: 16),
|
||
Text('本月还没有日记', style: theme.textTheme.bodyLarge),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
return Expanded(
|
||
child: ListView.builder(
|
||
padding: const EdgeInsets.all(16),
|
||
itemCount: allJournals.length,
|
||
itemBuilder: (context, index) {
|
||
final entry = allJournals[index];
|
||
final date = entry.key;
|
||
final journal = entry.value;
|
||
final isLast = index == allJournals.length - 1;
|
||
|
||
// 时间格式化 HH:mm
|
||
final timeStr =
|
||
'${journal.createdAt.hour.toString().padLeft(2, '0')}:${journal.createdAt.minute.toString().padLeft(2, '0')}';
|
||
|
||
// 摘要文本
|
||
final excerpt = journal.contentExcerpt ?? '';
|
||
|
||
return IntrinsicHeight(
|
||
child: Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
// 时间轴线
|
||
SizedBox(
|
||
width: 60,
|
||
child: Column(
|
||
children: [
|
||
// 圆点 + emoji
|
||
Container(
|
||
width: 40,
|
||
height: 40,
|
||
decoration: BoxDecoration(
|
||
color: _getMoodBgColor(journal.mood.value),
|
||
shape: BoxShape.circle,
|
||
),
|
||
alignment: Alignment.center,
|
||
child: Text(moodToEmoji(journal.mood), style: const TextStyle(fontSize: 20)),
|
||
),
|
||
// 竖线
|
||
if (!isLast)
|
||
Expanded(
|
||
child: Container(
|
||
width: 2,
|
||
color: colorScheme.outlineVariant,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
// 内容卡片
|
||
Expanded(
|
||
child: Padding(
|
||
padding: const EdgeInsets.only(bottom: 12),
|
||
child: Card(
|
||
elevation: 0,
|
||
shape: RoundedRectangleBorder(
|
||
borderRadius: AppRadius.mdBorder,
|
||
side: BorderSide(color: colorScheme.outlineVariant),
|
||
),
|
||
child: InkWell(
|
||
onTap: () => context.push('/editor?id=${journal.id}'),
|
||
borderRadius: AppRadius.mdBorder,
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(16),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
Text(
|
||
'${date.month}月${date.day}日',
|
||
style: theme.textTheme.labelSmall?.copyWith(
|
||
color: colorScheme.onSurfaceVariant,
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
Text(
|
||
timeStr,
|
||
style: theme.textTheme.labelSmall?.copyWith(
|
||
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.6),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 4),
|
||
Text(
|
||
journal.title,
|
||
style: theme.textTheme.titleSmall?.copyWith(
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
if (excerpt.isNotEmpty) ...[
|
||
const SizedBox(height: 4),
|
||
Text(
|
||
excerpt,
|
||
maxLines: 2,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: theme.textTheme.bodySmall?.copyWith(
|
||
color: colorScheme.onSurfaceVariant,
|
||
height: 1.4,
|
||
),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
},
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
// ===== 月份导航栏 =====
|
||
|
||
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: [
|
||
SizedBox(
|
||
width: 44,
|
||
height: 44,
|
||
child: OutlinedButton(
|
||
style: OutlinedButton.styleFrom(
|
||
shape: const CircleBorder(),
|
||
padding: EdgeInsets.zero,
|
||
side: BorderSide(
|
||
color: theme.colorScheme.outline.withValues(alpha: 0.3),
|
||
),
|
||
),
|
||
onPressed: onPrevious,
|
||
child: const Icon(Icons.chevron_left, size: 20),
|
||
),
|
||
),
|
||
Text(
|
||
monthName,
|
||
style: theme.textTheme.titleMedium?.copyWith(
|
||
fontWeight: FontWeight.bold,
|
||
),
|
||
),
|
||
SizedBox(
|
||
width: 44,
|
||
height: 44,
|
||
child: OutlinedButton(
|
||
style: OutlinedButton.styleFrom(
|
||
shape: const CircleBorder(),
|
||
padding: EdgeInsets.zero,
|
||
side: BorderSide(
|
||
color: theme.colorScheme.outline.withValues(alpha: 0.3),
|
||
),
|
||
),
|
||
onPressed: onNext,
|
||
child: const Icon(Icons.chevron_right, size: 20),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
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 days = _generateDays(month);
|
||
|
||
return Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||
child: Column(
|
||
children: days.map((week) {
|
||
return Row(
|
||
children: week.map((dayInfo) {
|
||
final dayKey = DateTime(
|
||
dayInfo.date.year, dayInfo.date.month, dayInfo.date.day);
|
||
final journals = journalsByDate[dayKey] ?? [];
|
||
final moodValue = journals.isNotEmpty ? journals.first.mood.value : null;
|
||
|
||
return Expanded(
|
||
child: _DayCell(
|
||
dayInfo: dayInfo,
|
||
isToday: _isToday(dayInfo.date),
|
||
isSelected: _isSameDay(dayInfo.date, selectedDay),
|
||
hasJournals: journals.isNotEmpty,
|
||
moodValue: moodValue,
|
||
onTap: () => onDaySelected(dayInfo.date),
|
||
),
|
||
);
|
||
}).toList(),
|
||
);
|
||
}).toList(),
|
||
),
|
||
);
|
||
}
|
||
|
||
List<List<_DayInfo>> _generateDays(DateTime month) {
|
||
final firstDay = DateTime(month.year, month.month, 1);
|
||
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.moodValue,
|
||
required this.onTap,
|
||
});
|
||
|
||
final _DayInfo dayInfo;
|
||
final bool isToday;
|
||
final bool isSelected;
|
||
final bool hasJournals;
|
||
final String? moodValue;
|
||
final VoidCallback onTap;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
final colorScheme = theme.colorScheme;
|
||
|
||
// 心情色彩背景
|
||
final moodBg = hasJournals && moodValue != null
|
||
? _getMoodBgColor(moodValue!)
|
||
: Colors.transparent;
|
||
|
||
return GestureDetector(
|
||
onTap: onTap,
|
||
behavior: HitTestBehavior.opaque,
|
||
child: Container(
|
||
height: 48,
|
||
margin: const EdgeInsets.all(2),
|
||
decoration: BoxDecoration(
|
||
color: isSelected
|
||
? colorScheme.primary
|
||
: moodBg != Colors.transparent
|
||
? moodBg
|
||
: isToday
|
||
? colorScheme.primaryContainer
|
||
: null,
|
||
borderRadius: AppRadius.smBorder,
|
||
border: isToday && !isSelected
|
||
? Border.all(color: colorScheme.primary, width: 2)
|
||
: null,
|
||
),
|
||
alignment: Alignment.center,
|
||
child: Stack(
|
||
clipBehavior: Clip.none,
|
||
children: [
|
||
Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
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 && moodBg == Colors.transparent)
|
||
Container(
|
||
width: 6,
|
||
height: 6,
|
||
margin: const EdgeInsets.only(top: 1),
|
||
decoration: BoxDecoration(
|
||
shape: BoxShape.circle,
|
||
color: isSelected ? colorScheme.onPrimary : AppColors.accent,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
// 日记条目指示点
|
||
if (hasJournals)
|
||
Positioned(
|
||
top: 3,
|
||
right: 5,
|
||
child: Container(
|
||
width: 5,
|
||
height: 5,
|
||
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.push('/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: AppRadius.mdBorder,
|
||
side: BorderSide(color: colorScheme.outlineVariant),
|
||
),
|
||
child: InkWell(
|
||
onTap: () => context.push('/editor?id=${journal.id}'),
|
||
borderRadius: AppRadius.mdBorder,
|
||
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(
|
||
moodToEmoji(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),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
},
|
||
);
|
||
}
|
||
}
|
||
|
||
// ===== 辅助函数 =====
|
||
|
||
/// 获取心情对应的日历格子背景色
|
||
Color _getMoodBgColor(String mood) {
|
||
return AppColors.moodCellColors[mood] ?? AppColors.secondarySoftLight;
|
||
}
|
||
|
||
/// 是否是今天
|
||
bool _isToday(DateTime date) {
|
||
final now = DateTime.now();
|
||
return date.year == now.year && date.month == now.month && date.day == now.day;
|
||
}
|
||
|
||
/// 是否同一天
|
||
bool _isSameDay(DateTime a, DateTime b) {
|
||
return a.year == b.year && a.month == b.month && a.day == b.day;
|
||
}
|
||
|
||
/// 周几名称
|
||
String _weekdayName(int weekday) {
|
||
return switch (weekday) {
|
||
1 => '周一',
|
||
2 => '周二',
|
||
3 => '周三',
|
||
4 => '周四',
|
||
5 => '周五',
|
||
6 => '周六',
|
||
7 => '周日',
|
||
_ => '',
|
||
};
|
||
}
|