Files
nj/app/lib/features/calendar/views/calendar_page.dart
iven b320641d9c
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
fix(app): 全链路验证修复 — 编译错误/CORS/迁移/启动脚本
前端修复:
- calendar_page: 移除不存在的 JournalEntry.content getter
- responsive_scaffold: 移除不存在的 notchThickness 参数
- splash_page: SingleTickerProvider → TickerProvider (多 AnimationController)
- profile_page: UserRoleType.name → .code (修复运行时崩溃)
- 导入缺失的 user.dart

后端修复:
- class_service: generate_class_code 取 UUID 后6位(随机部分)避免碰撞
- diary_role_seed: 移除不存在的 id 列,使用复合主键 ON CONFLICT

基础设施:
- config/default.toml: CORS 改为通配符(开发模式)
- scripts/dev.sh: 统一启动脚本(自动清理端口)
- docs/opendesign/: Open Design 设计规格 HTML 原型稿

验证结果: flutter analyze 0 error, cargo test 77/77 通过, 17个页面全部渲染正常
2026-06-02 01:03:58 +08:00

819 lines
26 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.
// 日历页面 — 月视图(心情色彩) + 周视图 + 时间轴
// 对齐 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/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));
},
),
// 视图模式切换
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: [
// 星期标题行
_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),
),
],
),
);
}
}
// ===== 周视图 =====
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(
_moodEmoji(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;
return IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 时间轴线
SizedBox(
width: 60,
child: Column(
children: [
// 圆点 + emoji
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: _getMoodBgColor(journal.mood.value),
shape: BoxShape.circle,
),
alignment: Alignment.center,
child: Text(_moodEmoji(journal.mood), style: const TextStyle(fontSize: 18)),
),
// 竖线
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: [
Text(
'${date.month}${date.day}',
style: theme.textTheme.labelSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 4),
Text(
journal.title,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
// 日记内容通过 JournalElement 管理,日历视图仅显示标题
// 后续可通过 elements 预览首段文字
],
),
),
),
),
),
),
],
),
);
},
),
);
}
}
// ===== 月份导航栏 =====
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 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.xsBorder,
border: isToday && !isSelected
? Border.all(color: colorScheme.primary, width: 2)
: null,
),
alignment: Alignment.center,
child: 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: 4,
height: 4,
margin: const EdgeInsets.only(top: 1),
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(
_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),
),
],
),
),
),
);
},
);
}
}
// ===== 辅助函数 =====
/// 获取心情对应的日历格子背景色
Color _getMoodBgColor(String mood) {
return AppColors.moodCellColors[mood] ?? AppColors.secondarySoftLight;
}
/// 心情 → emoji
String _moodEmoji(Mood mood) {
return switch (mood) {
Mood.happy => '😊',
Mood.calm => '😌',
Mood.sad => '😢',
Mood.angry => '😠',
Mood.thinking => '🤔',
};
}
/// 是否是今天
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 => '周日',
_ => '',
};
}