Files
nj/app/lib/features/calendar/views/calendar_page.dart
iven 181bfb1f3e fix(app): 对齐 Open Design spec — 字体/Token/首页/Tab栏/路由/Discover页
针对 docs/opendesign/warm-notes-design-spec.md 全面审查的修复:

## 🔴 阻断级修复(商用合规)
- 下载真实 Quicksand/Nunito 字体文件(原 0 字节)
- 添加 OFL.txt 许可证文件,履行 SIL Open Font License 分发义务

## 🟠 设计 Token 偏差
- AppRadius: 删除非规范的 xs=8px,所有引用迁移至 sm=10px
- AppColors.moodColors: 对齐 spec §3.6
  - happy #FFD93D → secondary #81B29A
  - calm #81B29A → tertiary #F2CC8F
  - sad #7B9CC4 → #5B7DB1
  - thinking #B8A9C9(淡紫,spec 无)→ #8B7E74
- AppShadows: blurRadius/alpha 精确对齐 spec §1 (12/20/32 + 0.06/0.08/0.12)
- DesignTokens: 补 spacing40 + 新增 safe-top/safe-bottom/tab-height/touch-min 常量

## 🟠 首页 §3.4 完全重构
- 新增问候语头部(xx好,小暖 + accent 色高亮名字)
- 新增 streak-badge pill 徽章(tertiary-soft + #B8860B 暖金)
- 心情选择器卡片背景从 primaryContainer 改为 surface(spec 规定 #FFFFFF)
- 心情卡片圆角 lg(22) → md(16) 对齐 spec
- 新增 today-card 渐变卡片 + 浮动右下圆形写按钮
- 新增 quick-stats 三栏统计(本月日记/连续天数/总日记数)
- 移除 AppBar 多余的贴纸/模板按钮,搜索按钮改路由到 /search
- HomeBloc 扩展 monthCount/totalCount 字段
- 日记卡片:72×72 预览图 + 标签摘要 + 心情圆点

## 🟠 路由 §3.12 + §3.13 拆分
- 新建 DiscoverPage (features/discover/views/discover_page.dart)
  - 搜索框(跳转 /search)
  - 每日推荐渐变卡片
  - 热门话题横向 chips(前 3 个 accent 高亮)
  - 精选模板 2 列网格
  - 达人日记列表
- /discover 路由从指向 SearchPage 改为 DiscoverPage
- 新增 /search 路由(全屏无 Tab)指向 SearchPage

## 🟠 Tab 栏 §2.2 重构
- 高度从 64px 改为 56+bottomPadding(含 safe-bottom,约 90px)
- 中心按钮从 CircularNotchedRectangle 凹槽改为 margin-top:-16px 凸起
- FAB 尺寸从默认改为 48×48 spec 规格
- FAB 图标从 edit_rounded 改为 add_rounded(spec §2.2)
- 删除未使用的 _navItems 旧常量

## 🟡 登录页圆角统一
- 移除 3 处 InputBorder 显式 mdBorder(16px) 覆盖
- 全局主题 smBorder(10px) 生效,对齐 spec
- 提交按钮圆角改为 pill(spec §2.6 Primary 按钮)

## 验证
- flutter analyze: 0 errors (剩余 40 个 warning/info 全为预存)
- flutter test: 84/85 通过(widget smoke test 预存失败,与本次无关)
2026-06-02 09:11:46 +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.smBorder,
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 => '周日',
_ => '',
};
}