// 周概览页面 — 7天条目 + 统计卡片 + 每日日记卡片 // 对齐 Open Design 原型稿 screens/weekly.html // 接入 JournalRepository 加载真实数据 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/core/utils/mood_utils.dart'; import 'package:nuanji_app/data/models/journal_entry.dart'; import 'package:nuanji_app/data/repositories/journal_repository.dart'; /// 周概览页面 class WeeklyPage extends StatefulWidget { const WeeklyPage({super.key}); @override State createState() => _WeeklyPageState(); } class _WeeklyPageState extends State { late DateTime _focusedWeekStart; List _journals = []; bool _isLoading = true; @override void initState() { super.initState(); final now = DateTime.now(); _focusedWeekStart = _startOfWeek(now); _loadWeekData(); } JournalRepository get _repo => context.read(); /// 获取某天的周一日期 DateTime _startOfWeek(DateTime date) { return date.subtract(Duration(days: date.weekday - 1)); } Future _loadWeekData() async { if (!mounted) return; setState(() => _isLoading = true); try { final weekEnd = _focusedWeekStart.add(const Duration(days: 7)); final journals = await _repo.getJournals( dateFrom: _focusedWeekStart, dateTo: weekEnd, ); if (mounted) { setState(() { _journals = journals; _isLoading = false; }); } } catch (e) { debugPrint('WeeklyPage._loadWeekData 失败: $e'); if (mounted) setState(() => _isLoading = false); } } void _goToPreviousWeek() { setState(() { _focusedWeekStart = _focusedWeekStart.subtract(const Duration(days: 7)); }); _loadWeekData(); } void _goToNextWeek() { setState(() { _focusedWeekStart = _focusedWeekStart.add(const Duration(days: 7)); }); _loadWeekData(); } /// 按日期索引日记: day-of-week (1=周一..7=周日) → JournalEntry 列表 Map> get _journalsByWeekday { final map = >{}; for (final j in _journals) { // 判断日记日期是否在本周范围内 final dayKey = j.date.difference(_focusedWeekStart).inDays; if (dayKey >= 0 && dayKey < 7) { final weekday = dayKey + 1; // 1=周一, 7=周日 (map[weekday] ??= []).add(j); } } return map; } @override Widget build(BuildContext context) { final theme = Theme.of(context); final colorScheme = theme.colorScheme; return Scaffold( body: SafeArea( child: Column( children: [ // 周头部导航 _WeekHeader( weekStart: _focusedWeekStart, onPrevious: _goToPreviousWeek, onNext: _goToNextWeek, ), // 可滚动内容区 Expanded( child: _isLoading ? const Center(child: CircularProgressIndicator()) : ListView( padding: const EdgeInsets.symmetric(horizontal: 20), children: [ const SizedBox(height: 16), // 7天条目(真实数据) _WeekStrip( weekStart: _focusedWeekStart, journalsByWeekday: _journalsByWeekday, ), const SizedBox(height: 20), // 本周总结(真实数据) _WeekSummary(journals: _journals), const SizedBox(height: 20), // 每日日记卡片(真实数据) ..._buildDayCards(theme, colorScheme), const SizedBox(height: 32), ], ), ), ], ), ), ); } List _buildDayCards(ThemeData theme, ColorScheme colorScheme) { final byWeekday = _journalsByWeekday; final cards = []; final weekNames = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']; // 按日期倒序生成卡片(最新的在上面) for (var i = 6; i >= 0; i--) { final weekday = i + 1; final dayJournals = byWeekday[weekday]; if (dayJournals == null || dayJournals.isEmpty) continue; final day = _focusedWeekStart.add(Duration(days: i)); final first = dayJournals.first; cards.add(_DayCard( weekday: weekNames[i], date: '${day.month}月${day.day}日', moodEmoji: moodToEmoji(first.mood), weatherEmoji: _weatherEmoji(first.weather), body: first.contentExcerpt ?? first.title, tags: first.tags.take(2).map((tag) { // 根据标签内容选择颜色 return (tag, AppColors.secondarySoftLight, const Color(0xFF2D7D46)); }).toList(), photoEmoji: dayJournals.any((j) => j.contentExcerpt != null && j.contentExcerpt!.contains('📷')) ? '📷' : null, )); } // 无日记时显示空状态 if (cards.isEmpty) { return [ SizedBox( height: 200, child: Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.edit_note_rounded, size: 48, color: colorScheme.onSurface.withValues(alpha: 0.2)), const SizedBox(height: 12), Text('这周还没有日记', style: theme.textTheme.bodyLarge?.copyWith( color: colorScheme.onSurface.withValues(alpha: 0.4), )), ], ), ), ), ]; } return cards; } String _weatherEmoji(Weather weather) => switch (weather) { Weather.sunny => '☀️', Weather.cloudy => '⛅', Weather.rainy => '🌧️', Weather.snowy => '❄️', Weather.windy => '💨', }; } // ===== 周头部导航 ===== class _WeekHeader extends StatelessWidget { const _WeekHeader({ required this.weekStart, required this.onPrevious, required this.onNext, }); final DateTime weekStart; final VoidCallback onPrevious; final VoidCallback onNext; @override Widget build(BuildContext context) { final theme = Theme.of(context); final colorScheme = theme.colorScheme; // 格式化: "2026年6月 第1周" final monthNames = [ '', '1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月', ]; final title = '${weekStart.year}年${monthNames[weekStart.month]} 第${_weekOfMonth(weekStart)}周'; 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, ), ], ), ); } /// 计算是当月第几周 int _weekOfMonth(DateTime date) { final firstDay = DateTime(date.year, date.month, 1); final offset = firstDay.weekday - 1; return ((date.day + offset) / 7).ceil(); } } /// 圆形导航按钮 (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), ), ); } } // ===== 7天条目(真实数据)===== class _WeekStrip extends StatelessWidget { const _WeekStrip({ required this.weekStart, required this.journalsByWeekday, }); final DateTime weekStart; final Map> journalsByWeekday; static const _weekNames = ['一', '二', '三', '四', '五', '六', '日']; @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; final now = DateTime.now(); return Row( children: List.generate(7, (i) { final day = weekStart.add(Duration(days: i)); final weekday = i + 1; final isToday = day.year == now.year && day.month == now.month && day.day == now.day; final dayJournals = journalsByWeekday[weekday] ?? []; final hasEntry = dayJournals.isNotEmpty; final moodEmoji = hasEntry ? moodToEmoji(dayJournals.first.mood) : '·'; return Expanded( child: GestureDetector( onTap: () { // TODO: 选择某天后刷新下方日记卡片 }, child: Container( padding: const EdgeInsets.symmetric(vertical: 12), decoration: BoxDecoration( color: isToday ? AppColors.accent : null, borderRadius: AppRadius.mdBorder, ), child: Column( children: [ // 周名 Text( _weekNames[i], style: TextStyle( fontSize: 11, color: isToday ? const Color(0xFFFFF8F0).withValues(alpha: 0.85) : colorScheme.onSurfaceVariant, ), ), const SizedBox(height: 4), // 日期数字 Text( '${day.day}', style: TextStyle( fontFamily: AppTypography.displayFont, fontSize: 20, fontWeight: FontWeight.w700, color: isToday ? const Color(0xFFFFF8F0) // accent-on : colorScheme.onSurface.withValues(alpha: 0.7), ), ), const SizedBox(height: 4), // 心情 emoji Text(moodEmoji, style: TextStyle( fontSize: hasEntry ? 16 : 14, color: hasEntry ? null : colorScheme.onSurface.withValues(alpha: 0.2), )), // 有日记: 日期下方4px小圆点 if (hasEntry && !isToday) Container( width: 4, height: 4, margin: const EdgeInsets.only(top: 4), decoration: BoxDecoration( shape: BoxShape.circle, color: AppColors.accent, ), ), if (hasEntry && isToday) Container( width: 4, height: 4, margin: const EdgeInsets.only(top: 4), decoration: const BoxDecoration( shape: BoxShape.circle, color: Color(0xFFFFF8F0), ), ), ], ), ), ), ); }), ); } } // ===== 本周总结卡片(真实数据)===== class _WeekSummary extends StatelessWidget { const _WeekSummary({required this.journals}); final List journals; @override Widget build(BuildContext context) { final theme = Theme.of(context); final colorScheme = theme.colorScheme; // 统计真实数据 final recordDays = journals.map((j) => j.date.day).toSet().length; final journalCount = journals.length; // 统计贴纸元素 — 从日记标签中估算(Phase 1 简化) final stickerCount = journals.fold( 0, (sum, j) => sum + j.tags.length, ); return Container( padding: const EdgeInsets.all(20), decoration: BoxDecoration( color: colorScheme.surface, borderRadius: AppRadius.mdBorder, boxShadow: AppShadows.soft(context), border: Border.all(color: colorScheme.outlineVariant), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( '本周总结', style: theme.textTheme.titleMedium?.copyWith( fontFamily: AppTypography.displayFont, fontWeight: FontWeight.w600, ), ), const SizedBox(height: 16), // 3个统计数字 Row( children: [ _SummaryItem( value: '$recordDays', label: '记录天数', valueColor: AppColors.accent, ), _SummaryItem( value: '$journalCount', label: '日记篇数', valueColor: AppColors.secondary, ), _SummaryItem( value: '$stickerCount', label: '使用标签', valueColor: AppColors.tertiary, ), ], ), const SizedBox(height: 16), // 心情分布条 _MoodDistributionBar(journals: journals), ], ), ); } } /// 心情分布条 — 从日记数据计算各心情占比 class _MoodDistributionBar extends StatelessWidget { const _MoodDistributionBar({required this.journals}); final List journals; static const _moodConfig = [ (Mood.happy, AppColors.secondary), (Mood.calm, AppColors.tertiary), (Mood.sad, Color(0xFF5B7DB1)), (Mood.angry, AppColors.accent), (Mood.thinking, Color(0xFF8B7E74)), ]; @override Widget build(BuildContext context) { if (journals.isEmpty) { return Container( height: 8, decoration: BoxDecoration( color: Theme.of(context).colorScheme.outlineVariant.withValues(alpha: 0.3), borderRadius: BorderRadius.circular(4), ), ); } // 统计各心情数量 final counts = {}; for (final j in journals) { counts[j.mood] = (counts[j.mood] ?? 0) + 1; } return Row( children: _moodConfig.where((c) => counts[c.$1] != null).map((config) { final count = counts[config.$1]!; return Expanded( flex: count, child: Container( height: 8, margin: const EdgeInsets.only(right: 2), decoration: BoxDecoration( color: config.$2, borderRadius: BorderRadius.circular(4), ), ), ); }).toList(), ); } } /// 单个统计项 class _SummaryItem extends StatelessWidget { const _SummaryItem({ required this.value, required this.label, required this.valueColor, }); final String value; final String label; final Color valueColor; @override Widget build(BuildContext context) { return Expanded( child: Column( children: [ Text( value, style: TextStyle( fontFamily: AppTypography.displayFont, fontSize: 24, fontWeight: FontWeight.w700, color: valueColor, ), ), const SizedBox(height: 2), Text( label, style: Theme.of(context).textTheme.labelSmall?.copyWith( color: Theme.of(context).colorScheme.onSurfaceVariant, ), ), ], ), ); } } // ===== 每日日记卡片 ===== class _DayCard extends StatelessWidget { const _DayCard({ required this.weekday, required this.date, required this.moodEmoji, required this.weatherEmoji, required this.body, required this.tags, this.photoEmoji, }); final String weekday; final String date; final String moodEmoji; final String weatherEmoji; final String body; final List<(String, Color, Color)> tags; // (label, bg, fg) final String? photoEmoji; @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: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // 头部: 日期 + 心情/weather emoji Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( '$weekday · $date', style: theme.textTheme.titleSmall?.copyWith( fontWeight: FontWeight.w600, color: colorScheme.onSurface.withValues(alpha: 0.7), ), ), Text( '$moodEmoji $weatherEmoji', style: const TextStyle(fontSize: 13), ), ], ), const SizedBox(height: 12), // 正文预览 (3行截断) Text( body, style: theme.textTheme.bodySmall?.copyWith( color: colorScheme.onSurfaceVariant, height: 1.6, ), maxLines: 3, overflow: TextOverflow.ellipsis, ), // 标签 pills if (tags.isNotEmpty) ...[ const SizedBox(height: 12), Wrap( spacing: 8, runSpacing: 4, children: tags.map((tag) { return Container( padding: const EdgeInsets.symmetric( horizontal: 10, vertical: 3, ), decoration: BoxDecoration( color: tag.$2, borderRadius: AppRadius.pillBorder, ), child: Text( tag.$1, style: TextStyle( fontSize: 11, fontWeight: FontWeight.w500, color: tag.$3, ), ), ); }).toList(), ), ], // 照片占位 if (photoEmoji != null) ...[ const SizedBox(height: 12), Container( width: double.infinity, height: 80, decoration: BoxDecoration( borderRadius: AppRadius.smBorder, gradient: LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [ colorScheme.surfaceContainerHighest .withValues(alpha: 0.6), colorScheme.outlineVariant.withValues(alpha: 0.3), ], ), ), alignment: Alignment.center, child: Text(photoEmoji!, style: const TextStyle(fontSize: 24)), ), ], ], ), ); } }