// 首页·日记流 — 严格对齐 spec §3.4 home-daily.html // // 视觉层级(从上到下): // 1. 问候语 + 日期(右上角搜索按钮) // 2. 连续记录徽章 streak-badge (pill) // 3. 心情选择器(5 选 1,bg=#FFFFFF surface 卡片) // 4. "今天的日记" 渐变卡片 + 浮动写按钮 // 5. 三栏统计(本月日记/连续天数/总日记数) // 6. 最近记录标题 + 查看全部 // 7. 日记卡片列表 // // 颜色规范(spec §7.1): // - 页面背景用 var(--bg) #FFF8F0(不是纯白) // - Card 用 var(--surface) #FFFFFF(与页面背景形成层次) import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import '../../../core/constants/design_tokens.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_radius.dart'; import '../../../core/theme/app_shadows.dart'; import '../../../core/theme/app_typography.dart'; import '../../../data/models/journal_entry.dart'; import '../../../data/repositories/journal_repository.dart'; import '../bloc/home_bloc.dart'; class HomePage extends StatelessWidget { const HomePage({super.key}); @override Widget build(BuildContext context) { return BlocProvider( create: (context) => HomeBloc( journalRepository: context.read(), )..add(const HomeLoadData()), child: const _HomeView(), ); } } class _HomeView extends StatelessWidget { const _HomeView(); @override Widget build(BuildContext context) { final theme = Theme.of(context); final isDark = theme.brightness == Brightness.dark; final bg = isDark ? AppColors.bgDark : AppColors.bgLight; return BlocBuilder( builder: (context, state) { final loaded = state is HomeLoaded ? state : const HomeLoaded(); final isLoading = state is HomeLoading; return Scaffold( backgroundColor: bg, body: isLoading ? const Center(child: CircularProgressIndicator()) : RefreshIndicator( onRefresh: () async { context.read().add(const HomeRefresh()); }, child: _buildContent(context, loaded), ), ); }, ); } Widget _buildContent(BuildContext context, HomeLoaded state) { final now = DateTime.now(); final greeting = _greeting(now.hour); final dateText = _formatDate(now); return SingleChildScrollView( physics: const AlwaysScrollableScrollPhysics(), padding: EdgeInsets.fromLTRB( DesignTokens.spacing20, MediaQuery.of(context).padding.top + DesignTokens.spacing8, DesignTokens.spacing20, DesignTokens.spacing24, ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _GreetingHeader( greeting: greeting, username: '小暖', dateText: dateText, onSearchTap: () => context.push('/search'), ), const SizedBox(height: DesignTokens.spacing16), if (state.streakDays > 0) ...[ _StreakBadge(days: state.streakDays), const SizedBox(height: DesignTokens.spacing16), ], _MoodSelectorCard( topMood: state.topMood, todayWeather: state.todayWeather, onMoodTap: (_) => context.push('/editor'), ), const SizedBox(height: DesignTokens.spacing20), _TodayCard( hasTodayEntry: state.hasTodayEntry, onTap: () => context.push('/editor'), ), const SizedBox(height: DesignTokens.spacing20), _QuickStats( monthCount: state.monthCount, streakDays: state.streakDays, totalCount: state.totalCount, ), const SizedBox(height: DesignTokens.spacing24), _SectionHeader( title: '最近记录', onSeeAll: () => context.go('/calendar'), ), const SizedBox(height: DesignTokens.spacing12), state.recentJournals.isEmpty ? const _EmptyJournalState() : _JournalList(journals: state.recentJournals), ], ), ); } String _greeting(int hour) { if (hour < 6) return '夜深了'; if (hour < 11) return '早上好'; if (hour < 14) return '中午好'; if (hour < 18) return '下午好'; if (hour < 22) return '晚上好'; return '夜深了'; } String _formatDate(DateTime now) { const weekdays = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']; final w = weekdays[now.weekday - 1]; return '${now.year}年${now.month}月${now.day}日 · $w'; } } /// 1. 顶部问候 + 日期 + 搜索按钮 class _GreetingHeader extends StatelessWidget { const _GreetingHeader({ required this.greeting, required this.username, required this.dateText, required this.onSearchTap, }); final String greeting; final String username; final String dateText; final VoidCallback onSearchTap; @override Widget build(BuildContext context) { final theme = Theme.of(context); final colorScheme = theme.colorScheme; final fg = colorScheme.onSurface; final muted = colorScheme.onSurfaceVariant; return Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( dateText, style: TextStyle(fontSize: 13, color: muted, fontWeight: FontWeight.w500), ), const SizedBox(height: 4), Text.rich( TextSpan( style: TextStyle( fontFamily: AppTypography.displayFont, fontSize: 30, fontWeight: FontWeight.w700, color: fg, height: 1.2, ), children: [ TextSpan(text: '$greeting,'), TextSpan( text: username, style: TextStyle(color: colorScheme.primary), ), ], ), ), ], ), ), InkWell( onTap: onSearchTap, customBorder: const CircleBorder(), child: Container( width: DesignTokens.touchMin, height: DesignTokens.touchMin, decoration: BoxDecoration( color: colorScheme.surface, shape: BoxShape.circle, boxShadow: AppShadows.soft(context), ), child: Icon(Icons.search_rounded, size: 20, color: fg), ), ), ], ); } } /// 2. 连续记录徽章 (pill, tertiary-soft 背景) class _StreakBadge extends StatelessWidget { const _StreakBadge({required this.days}); final int days; @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6), decoration: BoxDecoration( color: AppColors.tertiarySoftLight, borderRadius: AppRadius.pillBorder, ), child: Row( mainAxisSize: MainAxisSize.min, children: [ const Icon( Icons.local_fire_department_rounded, size: 14, color: Color(0xFFB8860B), ), const SizedBox(width: 4), Text( '连续记录 $days 天', style: const TextStyle( fontSize: 13, fontWeight: FontWeight.w600, color: Color(0xFFB8860B), ), ), ], ), ); } } /// 3. 心情选择器卡片 — bg=#FFFFFF, radius=16 class _MoodSelectorCard extends StatelessWidget { const _MoodSelectorCard({ required this.topMood, required this.todayWeather, required this.onMoodTap, }); final Mood? topMood; final Weather? todayWeather; final ValueChanged onMoodTap; static const _moods = [ ('😊', '开心', Mood.happy), ('😐', '平静', Mood.calm), ('😢', '难过', Mood.sad), ('😡', '生气', Mood.angry), ('🤔', '思考', Mood.thinking), ]; static const _weatherMap = { Weather.sunny: ('☀', '晴'), Weather.cloudy: ('☁', '多云'), Weather.rainy: ('🌧', '雨'), Weather.snowy: ('❄', '雪'), Weather.windy: ('💨', '风'), }; @override Widget build(BuildContext context) { final theme = Theme.of(context); return Container( width: double.infinity, padding: const EdgeInsets.symmetric( horizontal: DesignTokens.spacing20, vertical: DesignTokens.spacing16, ), decoration: BoxDecoration( color: theme.colorScheme.surface, borderRadius: AppRadius.mdBorder, boxShadow: AppShadows.soft(context), ), child: Column( children: [ Row( children: [ Text( '今天心情如何?', style: TextStyle( fontFamily: AppTypography.handwrittenFont, fontSize: 17, fontWeight: FontWeight.w600, color: theme.colorScheme.onSurface, ), ), const Spacer(), Builder(builder: (context) { final w = _weatherMap[todayWeather] ?? _weatherMap[Weather.sunny]!; return Text( '${w.$1} ${w.$2}', style: TextStyle(fontSize: 13, color: theme.colorScheme.onSurfaceVariant), ); }), ], ), const SizedBox(height: DesignTokens.spacing12), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: _moods.map((m) { final isTop = topMood == m.$3; return InkWell( onTap: () => onMoodTap(m.$3), customBorder: const CircleBorder(), child: _MoodOption( emoji: m.$1, label: m.$2, selected: isTop, ), ); }).toList(), ), ], ), ); } } class _MoodOption extends StatelessWidget { const _MoodOption({ required this.emoji, required this.label, required this.selected, }); final String emoji; final String label; final bool selected; @override Widget build(BuildContext context) { final theme = Theme.of(context); return Column( mainAxisSize: MainAxisSize.min, children: [ AnimatedContainer( duration: DesignTokens.animFast, width: DesignTokens.touchMin, height: DesignTokens.touchMin, alignment: Alignment.center, decoration: BoxDecoration( color: selected ? AppColors.surfaceWarmLight : Colors.transparent, borderRadius: AppRadius.mdBorder, ), child: Text(emoji, style: const TextStyle(fontSize: 28)), ), const SizedBox(height: 4), Text( label, style: TextStyle( fontSize: 11, fontWeight: selected ? FontWeight.w600 : FontWeight.w500, color: selected ? theme.colorScheme.primary : theme.colorScheme.onSurfaceVariant, ), ), ], ); } } /// 4. "今天的日记" 渐变卡片 + 浮动写按钮 class _TodayCard extends StatelessWidget { const _TodayCard({required this.hasTodayEntry, required this.onTap}); final bool hasTodayEntry; final VoidCallback onTap; @override Widget build(BuildContext context) { return Material( color: Colors.transparent, child: InkWell( onTap: onTap, borderRadius: AppRadius.lgBorder, child: Container( width: double.infinity, padding: const EdgeInsets.all(DesignTokens.spacing24), decoration: BoxDecoration( gradient: const LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [AppColors.accent, AppColors.tertiary], ), borderRadius: AppRadius.lgBorder, boxShadow: [ BoxShadow( color: AppColors.accent.withValues(alpha: 0.25), offset: const Offset(0, 4), blurRadius: 14, ), ], ), child: Stack( children: [ Positioned( right: -30, top: -30, child: Container( width: 120, height: 120, decoration: BoxDecoration( shape: BoxShape.circle, color: Colors.white.withValues(alpha: 0.12), ), ), ), Positioned( left: -20, bottom: -20, child: Container( width: 80, height: 80, decoration: BoxDecoration( shape: BoxShape.circle, color: Colors.white.withValues(alpha: 0.08), ), ), ), Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( '今天的日记', style: TextStyle( fontFamily: AppTypography.handwrittenFont, fontSize: 13, color: Colors.white.withValues(alpha: 0.85), ), ), const SizedBox(height: 8), Text( hasTodayEntry ? '继续今天的记录' : '写点什么吧...', style: const TextStyle( fontSize: 24, fontWeight: FontWeight.w700, color: AppColors.bgLight, ), ), const SizedBox(height: 6), Text( '记录一个温暖的瞬间...', style: TextStyle( fontSize: 13, color: Colors.white.withValues(alpha: 0.75), ), ), ], ), Positioned( right: DesignTokens.spacing20, bottom: DesignTokens.spacing20, child: Material( color: Colors.white, shape: const CircleBorder(), elevation: 4, child: InkWell( onTap: onTap, customBorder: const CircleBorder(), child: SizedBox( width: 48, height: 48, child: Icon(Icons.add_rounded, size: 22, color: AppColors.accent), ), ), ), ), ], ), ), ), ); } } /// 5. 三栏统计 class _QuickStats extends StatelessWidget { const _QuickStats({ required this.monthCount, required this.streakDays, required this.totalCount, }); final int monthCount; final int streakDays; final int totalCount; @override Widget build(BuildContext context) { return Row( children: [ Expanded(child: _stat(context, '$monthCount', '本月日记', AppColors.accent)), const SizedBox(width: DesignTokens.spacing12), Expanded(child: _stat(context, '$streakDays', '连续天数', AppColors.secondary)), const SizedBox(width: DesignTokens.spacing12), Expanded(child: _stat(context, '$totalCount', '总日记数', AppColors.fgLight)), ], ); } Widget _stat(BuildContext context, String num, String label, Color color) { final theme = Theme.of(context); return Container( padding: const EdgeInsets.all(DesignTokens.spacing16), decoration: BoxDecoration( color: theme.colorScheme.surface, borderRadius: AppRadius.mdBorder, boxShadow: AppShadows.soft(context), ), child: Column( children: [ Text( num, style: TextStyle( fontFamily: AppTypography.displayFont, fontSize: 30, fontWeight: FontWeight.w700, color: color, ), ), const SizedBox(height: 2), Text( label, style: TextStyle(fontSize: 11, color: theme.colorScheme.onSurfaceVariant), ), ], ), ); } } /// 6. 区块标题 class _SectionHeader extends StatelessWidget { const _SectionHeader({required this.title, required this.onSeeAll}); final String title; final VoidCallback onSeeAll; @override Widget build(BuildContext context) { final theme = Theme.of(context); return Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( title, style: TextStyle( fontFamily: AppTypography.displayFont, fontSize: 20, fontWeight: FontWeight.w700, color: theme.colorScheme.onSurface, ), ), TextButton( onPressed: onSeeAll, style: TextButton.styleFrom( foregroundColor: theme.colorScheme.primary, minimumSize: const Size(44, 32), padding: const EdgeInsets.symmetric(horizontal: 8), ), child: const Text('查看全部', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500)), ), ], ); } } /// 7. 日记列表 class _JournalList extends StatelessWidget { const _JournalList({required this.journals}); final List journals; @override Widget build(BuildContext context) { return Column( children: journals .map((j) => Padding( padding: const EdgeInsets.only(bottom: DesignTokens.spacing12), child: _JournalCard(journal: j), )) .toList(), ); } } class _JournalCard extends StatelessWidget { const _JournalCard({required this.journal}); final JournalEntry journal; String _moodEmoji(Mood m) => switch (m) { Mood.happy => '😊', Mood.calm => '😐', Mood.sad => '😢', Mood.angry => '😡', Mood.thinking => '🤔', }; @override Widget build(BuildContext context) { final theme = Theme.of(context); final moodColor = AppColors.moodColors[journal.mood.value] ?? AppColors.accent; final excerpt = journal.contentExcerpt?.isNotEmpty == true ? journal.contentExcerpt! : (journal.tags.isEmpty ? '点击查看详情' : journal.tags.take(3).map((t) => '#$t').join(' ')); return Material( color: theme.colorScheme.surface, borderRadius: AppRadius.mdBorder, child: InkWell( onTap: () => context.push('/editor?id=${journal.id}'), borderRadius: AppRadius.mdBorder, child: Container( padding: const EdgeInsets.all(DesignTokens.spacing16), decoration: BoxDecoration( borderRadius: AppRadius.mdBorder, border: Border.all(color: theme.colorScheme.outlineVariant), ), child: Row( children: [ Container( width: 72, height: 72, decoration: BoxDecoration( color: AppColors.surfaceWarmLight, borderRadius: AppRadius.smBorder, ), alignment: Alignment.center, child: Text(_moodEmoji(journal.mood), style: const TextStyle(fontSize: 32)), ), const SizedBox(width: DesignTokens.spacing16), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( '${journal.date.month}月${journal.date.day}日', style: TextStyle(fontSize: 11, color: theme.colorScheme.onSurfaceVariant), ), const SizedBox(height: 2), Text( journal.title, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontFamily: AppTypography.displayFont, fontSize: 15, fontWeight: FontWeight.w600, color: theme.colorScheme.onSurface, ), ), const SizedBox(height: 4), Text( excerpt, maxLines: 2, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: 13, color: theme.colorScheme.onSurfaceVariant, height: 1.5), ), ], ), ), const SizedBox(width: 8), Container( width: 28, height: 28, decoration: BoxDecoration( color: moodColor.withValues(alpha: 0.15), shape: BoxShape.circle, ), alignment: Alignment.center, child: Text(_moodEmoji(journal.mood), style: const TextStyle(fontSize: 14)), ), ], ), ), ), ); } } class _EmptyJournalState extends StatelessWidget { const _EmptyJournalState(); @override Widget build(BuildContext context) { final theme = Theme.of(context); return Center( child: Padding( padding: const EdgeInsets.symmetric(vertical: DesignTokens.spacing40), child: Column( children: [ Icon( Icons.auto_stories_rounded, size: 64, color: theme.colorScheme.onSurface.withValues(alpha: 0.2), ), const SizedBox(height: DesignTokens.spacing16), Text( '开始你的第一篇手账日记吧!', style: theme.textTheme.bodyLarge?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), const SizedBox(height: DesignTokens.spacing24), FilledButton.icon( onPressed: () => context.push('/editor'), icon: const Icon(Icons.add_rounded), label: const Text('写日记'), style: FilledButton.styleFrom( backgroundColor: AppColors.accent, foregroundColor: AppColors.bgLight, shape: RoundedRectangleBorder(borderRadius: AppRadius.pillBorder), padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), ), ), ], ), ), ); } }