// 月度概览页面 — 心情色彩月历 + 月度统计 + 精选日记 // 对齐 Open Design 原型稿 screens/monthly.html import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:nuanji_app/core/theme/app_colors.dart'; import 'package:nuanji_app/core/theme/app_radius.dart'; import 'package:nuanji_app/core/theme/app_shadows.dart'; import 'package:nuanji_app/core/theme/app_typography.dart'; import 'package:nuanji_app/data/models/journal_entry.dart'; import 'package:nuanji_app/data/models/journal_element.dart'; import 'package:nuanji_app/data/repositories/journal_repository.dart'; /// 月度概览页面 class MonthlyPage extends StatefulWidget { final JournalRepository? journalRepository; const MonthlyPage({super.key, this.journalRepository}); @override State createState() => _MonthlyPageState(); } class _MonthlyPageState extends State { late DateTime _focusedMonth; List _journals = []; int _photoCount = 0; @override void initState() { super.initState(); _focusedMonth = DateTime.now(); _loadJournals(); } JournalRepository get _repo => widget.journalRepository ?? context.read(); Future _loadJournals() async { final firstDay = DateTime(_focusedMonth.year, _focusedMonth.month, 1); // 下月 1 号作为上界(开区间),所以用 month+1 final nextMonth = DateTime(_focusedMonth.year, _focusedMonth.month + 1, 1); final journals = await _repo.getJournals( dateFrom: firstDay, dateTo: nextMonth, ); // 统计照片元素数量 var photoCount = 0; for (final journal in journals) { try { final elements = await _repo.getElements(journal.id); photoCount += elements.where((e) => e.elementType == ElementType.image).length; } catch (e) { debugPrint('MonthlyPage: 加载日记 ${journal.id} 元素失败: $e'); // 单个日记加载元素失败不影响整体统计 } } if (mounted) { setState(() { _journals = journals; _photoCount = photoCount; }); } } void _goToPreviousMonth() { setState(() { _focusedMonth = DateTime(_focusedMonth.year, _focusedMonth.month - 1); }); _loadJournals(); } void _goToNextMonth() { setState(() { _focusedMonth = DateTime(_focusedMonth.year, _focusedMonth.month + 1); }); _loadJournals(); } @override Widget build(BuildContext context) { return Scaffold( body: SafeArea( child: Column( children: [ // 月头部导航 _MonthHeader( month: _focusedMonth, onPrevious: _goToPreviousMonth, onNext: _goToNextMonth, ), // 可滚动内容区 Expanded( child: ListView( padding: const EdgeInsets.symmetric(horizontal: 20), children: [ const SizedBox(height: 16), // 心情色彩月历 _MoodCalendar(month: _focusedMonth, journals: _journals), const SizedBox(height: 20), // 月度统计 2x2 _MonthSummary(journals: _journals, photoCount: _photoCount), const SizedBox(height: 20), // 精选日记 _Highlights(journals: _journals), const SizedBox(height: 32), ], ), ), ], ), ), ); } } // ===== 月头部导航 ===== class _MonthHeader extends StatelessWidget { const _MonthHeader({ 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 colorScheme = theme.colorScheme; final title = '${month.year}年${month.month}月'; return Padding( padding: const EdgeInsets.fromLTRB(20, 8, 12, 0), child: Row( children: [ Expanded( child: Text( title, style: theme.textTheme.headlineSmall?.copyWith( fontFamily: AppTypography.displayFont, fontWeight: FontWeight.w700, ), ), ), _NavButton( icon: Icons.chevron_left_rounded, onTap: onPrevious, borderColor: colorScheme.outline, foregroundColor: colorScheme.onSurface, ), const SizedBox(width: 8), _NavButton( icon: Icons.chevron_right_rounded, onTap: onNext, borderColor: colorScheme.outline, foregroundColor: colorScheme.onSurface, ), ], ), ); } } /// 圆形导航按钮 (44px 触摸目标) class _NavButton extends StatelessWidget { const _NavButton({ required this.icon, required this.onTap, required this.borderColor, required this.foregroundColor, }); final IconData icon; final VoidCallback onTap; final Color borderColor; final Color foregroundColor; @override Widget build(BuildContext context) { return SizedBox( width: 44, height: 44, child: OutlinedButton( onPressed: onTap, style: OutlinedButton.styleFrom( padding: EdgeInsets.zero, shape: const CircleBorder(), side: BorderSide(color: borderColor, width: 1.5), foregroundColor: foregroundColor, backgroundColor: Theme.of(context).colorScheme.surface, ), child: Icon(icon, size: 18), ), ); } } // ===== 心情色彩月历 ===== class _MoodCalendar extends StatelessWidget { const _MoodCalendar({required this.month, required this.journals}); final DateTime month; final List journals; // 心情 → emoji(对齐 Mood 枚举: happy/calm/sad/angry/thinking) static const _moodEmojis = { Mood.happy: '😊', Mood.calm: '😌', Mood.sad: '😢', Mood.angry: '😡', Mood.thinking: '🤔', }; // 心情 → 背景色 static const _moodBgColors = { Mood.happy: AppColors.secondarySoftLight, Mood.angry: AppColors.roseSoftLight, Mood.calm: AppColors.tertiarySoftLight, Mood.sad: Color(0xFFD4DDE8), Mood.thinking: Color(0xFFE8E4E0), }; @override Widget build(BuildContext context) { final theme = Theme.of(context); final colorScheme = theme.colorScheme; final now = DateTime.now(); return Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: colorScheme.surface, borderRadius: AppRadius.mdBorder, boxShadow: AppShadows.soft(context), border: Border.all(color: colorScheme.outlineVariant), ), child: Column( children: [ // 星期标题行 _WeekdayRow(colorScheme: colorScheme), const SizedBox(height: 8), // 7列网格 _buildGrid(context, now), ], ), ); } Widget _buildGrid(BuildContext context, DateTime now) { final firstDay = DateTime(month.year, month.month, 1); // 周一=0 → 偏移量; weekday 返回 1(周一)..7(周日) final startOffset = firstDay.weekday - 1; // 周一开头 final daysInMonth = DateTime(month.year, month.month + 1, 0).day; // 按日期建索引:day → JournalEntry final journalByDay = {}; for (final j in journals) { if (j.date.year == month.year && j.date.month == month.month) { journalByDay[j.date.day] = j; } } final cells = []; // 空白填充 for (var i = 0; i < startOffset; i++) { cells.add(const SizedBox.shrink()); } for (var d = 1; d <= daysInMonth; d++) { final isToday = now.year == month.year && now.month == month.month && now.day == d; final entry = journalByDay[d]; final mood = entry?.mood; final bgColor = mood != null ? (_moodBgColors[mood] ?? Colors.transparent) : Colors.transparent; final emoji = mood != null ? (_moodEmojis[mood] ?? '') : ''; cells.add( _MoodCell( day: d, emoji: emoji, bgColor: bgColor, isToday: isToday, ), ); } return GridView.count( crossAxisCount: 7, shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), mainAxisSpacing: 3, crossAxisSpacing: 3, childAspectRatio: 1, children: cells, ); } } /// 星期标题行 class _WeekdayRow extends StatelessWidget { const _WeekdayRow({required this.colorScheme}); final ColorScheme colorScheme; @override Widget build(BuildContext context) { const weekdays = ['一', '二', '三', '四', '五', '六', '日']; return Row( children: weekdays.map((day) { return Expanded( child: Center( child: Text( day, style: TextStyle( fontSize: 11, fontWeight: FontWeight.w500, color: colorScheme.onSurfaceVariant, ), ), ), ); }).toList(), ); } } /// 单个心情格子 class _MoodCell extends StatelessWidget { const _MoodCell({ required this.day, required this.emoji, required this.bgColor, required this.isToday, }); final int day; final String emoji; final Color bgColor; final bool isToday; @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; return GestureDetector( onTap: () { // TODO: 选择日期,跳转详情 }, child: Container( decoration: BoxDecoration( color: bgColor, borderRadius: BorderRadius.circular(6), border: isToday ? Border.all(color: AppColors.accent, width: 2) : null, ), alignment: Alignment.center, child: Column( mainAxisSize: MainAxisSize.min, children: [ Text( '$day', style: TextStyle( fontSize: 12, color: colorScheme.onSurface, fontWeight: isToday ? FontWeight.w700 : FontWeight.w400, ), ), const SizedBox(height: 1), Text( emoji, style: const TextStyle(fontSize: 10), ), ], ), ), ); } } // ===== 月度统计 2x2 ===== class _MonthSummary extends StatelessWidget { const _MonthSummary({required this.journals, required this.photoCount}); final List journals; final int photoCount; /// 计算最长连续写日记天数 int _calcLongestStreak() { if (journals.isEmpty) return 0; final days = journals.map((j) => j.date.day).toSet().toList()..sort(); int longest = 1; int current = 1; for (var i = 1; i < days.length; i++) { if (days[i] == days[i - 1] + 1) { current++; if (current > longest) longest = current; } else { current = 1; } } return longest; } /// 计算"好心情"(happy/calm)占比 String _calcGoodMoodPercent() { if (journals.isEmpty) return '0%'; final good = journals.where( (j) => j.mood == Mood.happy || j.mood == Mood.calm, ).length; return '${((good / journals.length) * 100).round()}%'; } @override Widget build(BuildContext context) { final theme = Theme.of(context); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( '本月总结', style: theme.textTheme.headlineSmall?.copyWith( fontFamily: AppTypography.displayFont, fontWeight: FontWeight.w700, ), ), const SizedBox(height: 16), GridView.count( crossAxisCount: 2, shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), mainAxisSpacing: 12, crossAxisSpacing: 12, childAspectRatio: 1.2, children: [ _StatCard( icon: '📝', value: '${journals.length}', label: '日记篇数', bgColor: AppColors.tertiarySoftLight, valueColor: const Color(0xFFB8860B), ), _StatCard( icon: '🔥', value: '${_calcLongestStreak()}', label: '最长连续', bgColor: AppColors.secondarySoftLight, valueColor: const Color(0xFF2D7D46), ), _StatCard( icon: '😊', value: _calcGoodMoodPercent(), label: '好心情占比', bgColor: AppColors.roseSoftLight, valueColor: const Color(0xFF9B4D4D), ), _StatCard( icon: '📸', value: '$photoCount', label: '照片数量', bgColor: const Color(0xFFD4DDE8), valueColor: const Color(0xFF4A6B8A), ), ], ), ], ); } } /// 单张统计小卡片 class _StatCard extends StatelessWidget { const _StatCard({ required this.icon, required this.value, required this.label, required this.bgColor, required this.valueColor, }); final String icon; final String value; final String label; final Color bgColor; final Color valueColor; @override Widget build(BuildContext context) { final theme = Theme.of(context); return Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: bgColor, borderRadius: AppRadius.mdBorder, ), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Text(icon, style: const TextStyle(fontSize: 24)), const SizedBox(height: 8), Text( value, style: TextStyle( fontFamily: AppTypography.displayFont, fontSize: 24, fontWeight: FontWeight.w700, color: valueColor, ), ), const SizedBox(height: 2), Text( label, style: theme.textTheme.labelSmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ], ), ); } } // ===== 精选日记 ===== class _Highlights extends StatelessWidget { const _Highlights({required this.journals}); final List journals; static const _badgeConfig = { Mood.happy: (badge: '最佳心情', bg: AppColors.roseSoftLight, fg: Color(0xFF9B4D4D)), Mood.calm: (badge: '平静时光', bg: AppColors.tertiarySoftLight, fg: Color(0xFFB8860B)), Mood.sad: (badge: '真实记录', bg: Color(0xFFD4DDE8), fg: Color(0xFF4A6B8A)), Mood.angry: (badge: '真情流露', bg: AppColors.roseSoftLight, fg: Color(0xFF9B4D4D)), Mood.thinking: (badge: '深度思考', bg: AppColors.secondarySoftLight, fg: Color(0xFF2D7D46)), }; @override Widget build(BuildContext context) { final theme = Theme.of(context); // 按日期降序取前 3 篇 final top = List.from(journals) ..sort((a, b) => b.date.compareTo(a.date)); final highlights = top.take(3).toList(); if (highlights.isEmpty) { return const SizedBox.shrink(); } return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( '本月精选', style: theme.textTheme.headlineSmall?.copyWith( fontFamily: AppTypography.displayFont, fontWeight: FontWeight.w700, ), ), const SizedBox(height: 16), ...highlights.map((entry) { final mood = entry.mood; final emoji = _MoodCalendar._moodEmojis[mood] ?? '📝'; final emojiBg = _MoodCalendar._moodBgColors[mood] ?? AppColors.tertiarySoftLight; final cfg = _badgeConfig[mood] ?? (badge: '日记', bg: AppColors.tertiarySoftLight, fg: const Color(0xFFB8860B)); final dateStr = '${entry.date.month}月${entry.date.day}日'; return _HighlightCard( emoji: emoji, emojiBg: emojiBg, date: dateStr, title: entry.title, badge: cfg.badge, badgeBg: cfg.bg, badgeFg: cfg.fg, ); }), ], ); } } /// 单张精选日记卡片 class _HighlightCard extends StatelessWidget { const _HighlightCard({ required this.emoji, required this.emojiBg, required this.date, required this.title, required this.badge, required this.badgeBg, required this.badgeFg, }); final String emoji; final Color emojiBg; final String date; final String title; final String badge; final Color badgeBg; final Color badgeFg; @override Widget build(BuildContext context) { final theme = Theme.of(context); final colorScheme = theme.colorScheme; return Container( margin: const EdgeInsets.only(bottom: 12), padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: colorScheme.surface, borderRadius: AppRadius.mdBorder, boxShadow: AppShadows.soft(context), border: Border.all(color: colorScheme.outlineVariant), ), child: Row( children: [ // 48x48 emoji 圆圈 Container( width: 48, height: 48, decoration: BoxDecoration( color: emojiBg, shape: BoxShape.circle, ), alignment: Alignment.center, child: Text(emoji, style: const TextStyle(fontSize: 24)), ), const SizedBox(width: 12), // 标题 + 日期 Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( date, style: theme.textTheme.labelSmall?.copyWith( color: colorScheme.onSurfaceVariant, ), ), const SizedBox(height: 2), Text( title, style: theme.textTheme.titleMedium?.copyWith( fontFamily: AppTypography.displayFont, fontWeight: FontWeight.w600, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), ], ), ), // badge pill Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), decoration: BoxDecoration( color: badgeBg, borderRadius: AppRadius.pillBorder, ), child: Text( badge, style: TextStyle( fontSize: 11, fontWeight: FontWeight.w600, color: badgeFg, ), ), ), ], ), ); } }