// 日历页面 — 月视图(心情色彩) + 周视图 + 时间轴 // 对齐 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(), )..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( 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() .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().add(CalendarMonthChanged(prev)); }, onNext: () { final next = DateTime( loaded.focusedMonth.year, loaded.focusedMonth.month + 1, ); context.read().add(CalendarMonthChanged(next)); }, ), // 视图模式切换 Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), child: SegmentedButton( 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() .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().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().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> journalsByDate; final ValueChanged 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> _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 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 => '周日', _ => '', }; }