// 日历页面 — 月视图 + 日记列表 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(), )..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)); }, ), // 星期标题行 _WeekdayHeader(colorScheme: 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 _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 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> _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 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 => '🤔', }; } }