// 发现页 — 严格对齐 spec §3.12 discover.html // // 视觉层级(从上到下): // 1. 搜索框 (pill 形状) // 2. 每日推荐卡片 inspiration-card (accent→tertiary 渐变) // 3. 热门话题 hot-topics (横向滚动 chips) // 4. 精选模板 featured-templates (2 列网格) // 5. 达人日记 expert-diaries (纵向列表) // // 注意:本页是发现/灵感浏览,区别于 /search(主动搜索) // 数据来源:GET /diary/discover → DiscoverBloc 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 '../../../widgets/empty_state_widget.dart'; import '../../../widgets/error_state_widget.dart'; import '../bloc/discover_bloc.dart'; import '../models/discover_models.dart'; class DiscoverPage extends StatelessWidget { const DiscoverPage({super.key}); @override Widget build(BuildContext context) { return Scaffold( backgroundColor: _bgColor(context), body: SafeArea( child: RefreshIndicator( onRefresh: () async { context.read().add(const DiscoverRefresh()); // 等待状态变化完成 await context.read().stream.firstWhere( (s) => s is DiscoverLoaded || s is DiscoverError, orElse: () => const DiscoverLoaded(DiscoverData()), ); }, child: BlocBuilder( builder: (context, state) { return SingleChildScrollView( physics: const AlwaysScrollableScrollPhysics(), padding: const EdgeInsets.symmetric( horizontal: DesignTokens.spacing20), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const SizedBox(height: DesignTokens.spacing12), _SearchBar(onTap: () => context.push('/search')), const SizedBox(height: DesignTokens.spacing20), _buildContent(context, state), const SizedBox(height: DesignTokens.spacing24), ], ), ); }, ), ), ), ); } /// 根据状态构建主要内容 Widget _buildContent(BuildContext context, DiscoverState state) { return switch (state) { DiscoverInitial() => _buildLoading(context), DiscoverLoading() => _buildLoading(context), DiscoverLoaded(:final data) => _buildLoaded(context, data), DiscoverError(:final message) => _buildError(context, message), }; } /// 加载中状态 — 骨架占位 Widget _buildLoading(BuildContext context) { return const Column( children: [ _LoadingSkeleton(height: 140), SizedBox(height: DesignTokens.spacing24), _LoadingSkeleton(height: 44), SizedBox(height: DesignTokens.spacing24), _LoadingSkeleton(height: 200), ], ); } /// 加载成功 — 渲染真实数据 Widget _buildLoaded(BuildContext context, DiscoverData data) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // 每日推荐 _InspirationCard(item: data.dailyInspiration), // 热门话题 if (data.hotTopics.isNotEmpty) ...[ const SizedBox(height: DesignTokens.spacing24), const _SectionTitle(title: '热门话题'), const SizedBox(height: DesignTokens.spacing12), _HotTopicsChips(topics: data.hotTopics), ], // 精选模板 if (data.featuredTemplates.isNotEmpty) ...[ const SizedBox(height: DesignTokens.spacing24), const _SectionTitle(title: '精选模板'), const SizedBox(height: DesignTokens.spacing12), _FeaturedTemplatesGrid(templates: data.featuredTemplates), ], // 达人日记 if (data.expertDiaries.isNotEmpty) ...[ const SizedBox(height: DesignTokens.spacing24), const _SectionTitle(title: '达人日记'), const SizedBox(height: DesignTokens.spacing12), _ExpertDiariesList(diaries: data.expertDiaries), ], // 全部为空时的占位提示 if (data.dailyInspiration == null && data.hotTopics.isEmpty && data.featuredTemplates.isEmpty && data.expertDiaries.isEmpty) _buildEmptyHint(context), ], ); } /// 错误状态 Widget _buildError(BuildContext context, String message) { return ErrorStateWidget( message: message, onRetry: () => context.read().add(const DiscoverLoadData()), ); } /// 空数据提示 Widget _buildEmptyHint(BuildContext context) { return const EmptyStateWidget( icon: Icons.explore_rounded, title: '还没有发现内容', subtitle: '试试写一篇日记分享给大家吧', ); } Color _bgColor(BuildContext context) { final theme = Theme.of(context); return theme.brightness == Brightness.dark ? AppColors.bgDark : AppColors.bgLight; } } /// 加载骨架占位 class _LoadingSkeleton extends StatelessWidget { const _LoadingSkeleton({required this.height}); final double height; @override Widget build(BuildContext context) { return Container( width: double.infinity, height: height, decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerHighest .withValues(alpha: 0.3), borderRadius: AppRadius.lgBorder, ), ); } } /// 1. 搜索框(点击跳转 /search) class _SearchBar extends StatelessWidget { const _SearchBar({required this.onTap}); final VoidCallback onTap; @override Widget build(BuildContext context) { final theme = Theme.of(context); return InkWell( onTap: onTap, borderRadius: AppRadius.pillBorder, child: Container( height: 48, padding: const EdgeInsets.symmetric(horizontal: DesignTokens.spacing16), decoration: BoxDecoration( color: theme.colorScheme.surface, borderRadius: AppRadius.pillBorder, border: Border.all(color: theme.colorScheme.outlineVariant), ), child: Row( children: [ Icon(Icons.search_rounded, size: 20, color: theme.colorScheme.onSurfaceVariant), const SizedBox(width: DesignTokens.spacing12), Text( '搜索日记、模板、话题...', style: TextStyle( fontSize: 14, color: theme.colorScheme.onSurfaceVariant, ), ), ], ), ), ); } } /// 2. 每日推荐卡片(渐变背景) class _InspirationCard extends StatelessWidget { const _InspirationCard({required this.item}); final InspirationItem? item; @override Widget build(BuildContext context) { if (item == null) { // 无推荐日记时的占位卡片 return Container( width: double.infinity, padding: const EdgeInsets.all(DesignTokens.spacing20), decoration: BoxDecoration( gradient: const LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [AppColors.accent, AppColors.tertiary], ), borderRadius: AppRadius.lgBorder, ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('今日推荐', style: TextStyle( fontSize: 11, fontWeight: FontWeight.w600, color: Colors.white.withValues(alpha: 0.85), letterSpacing: 0.5, )), const SizedBox(height: DesignTokens.spacing12), const Text('今天还没有推荐日记', style: TextStyle(fontSize: 16, color: Colors.white)), const SizedBox(height: 4), Text('写下你的日记,可能出现在这里哦 ✨', style: TextStyle( fontSize: 12, color: Colors.white.withValues(alpha: 0.7))), ], ), ); } final emoji = DiscoverData.moodToEmoji(item!.mood); return Container( width: double.infinity, padding: const EdgeInsets.all(DesignTokens.spacing20), 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.2), offset: const Offset(0, 4), blurRadius: 14, ), ], ), child: Stack( children: [ // 装饰圆 Positioned( right: -20, top: -20, child: Container( width: 120, height: 120, decoration: BoxDecoration( shape: BoxShape.circle, color: Colors.white.withValues(alpha: 0.12), ), ), ), Positioned( left: -10, 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( fontSize: 11, fontWeight: FontWeight.w600, color: Colors.white.withValues(alpha: 0.85), letterSpacing: 0.5, ), ), const SizedBox(height: DesignTokens.spacing12), Row( children: [ Container( width: 80, height: 80, decoration: BoxDecoration( color: Colors.white.withValues(alpha: 0.2), borderRadius: AppRadius.mdBorder, ), alignment: Alignment.center, child: Text(emoji, style: const TextStyle(fontSize: 36)), ), const SizedBox(width: DesignTokens.spacing16), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( item!.title, style: const TextStyle( fontSize: 20, fontWeight: FontWeight.w700, color: Colors.white, height: 1.25, ), maxLines: 2, overflow: TextOverflow.ellipsis, ), const SizedBox(height: 6), Text( '${item!.authorName} · ${_formatDate(item!.date)}', style: TextStyle( fontSize: 12, color: Colors.white.withValues(alpha: 0.75), ), ), ], ), ), ], ), ], ), ], ), ); } String _formatDate(DateTime date) { return '${date.month}月${date.day}日'; } } class _SectionTitle extends StatelessWidget { const _SectionTitle({required this.title}); final String title; @override Widget build(BuildContext context) { final theme = Theme.of(context); return Text( title, style: TextStyle( fontFamily: AppTypography.displayFont, fontSize: 20, fontWeight: FontWeight.w700, color: theme.colorScheme.onSurface, ), ); } } /// 3. 热门话题(横向滚动 chips) class _HotTopicsChips extends StatelessWidget { const _HotTopicsChips({required this.topics}); final List topics; @override Widget build(BuildContext context) { final theme = Theme.of(context); return SizedBox( height: 44, child: ListView.separated( scrollDirection: Axis.horizontal, itemCount: topics.length, separatorBuilder: (_, __) => const SizedBox(width: DesignTokens.spacing8), itemBuilder: (context, index) { final isHot = index < 3; return Container( padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6), decoration: BoxDecoration( color: isHot ? theme.colorScheme.primary : theme.colorScheme.surface, borderRadius: AppRadius.pillBorder, border: isHot ? null : Border.all(color: theme.colorScheme.outlineVariant), ), alignment: Alignment.center, child: Text( '#${topics[index].tag}', style: TextStyle( fontSize: 13, fontWeight: FontWeight.w600, color: isHot ? theme.colorScheme.onPrimary : theme.colorScheme.onSurface, ), ), ); }, ), ); } } /// 4. 精选模板(2 列网格) class _FeaturedTemplatesGrid extends StatelessWidget { const _FeaturedTemplatesGrid({required this.templates}); final List templates; @override Widget build(BuildContext context) { return GridView.builder( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 2, mainAxisSpacing: DesignTokens.spacing12, crossAxisSpacing: DesignTokens.spacing12, childAspectRatio: 0.85, ), itemCount: templates.length, itemBuilder: (context, index) { final t = templates[index]; return _TemplateCard( emoji: t.emoji, name: t.name, usage: t.usageText, bg: _categoryColor(t.category), ); }, ); } Color _categoryColor(String? category) { return switch (category) { '日常' => AppColors.secondarySoftLight, '校园' => AppColors.tertiarySoftLight, '心情' => AppColors.roseSoftLight, '旅行' => AppColors.secondarySoftLight, _ => AppColors.secondarySoftLight, }; } } class _TemplateCard extends StatelessWidget { const _TemplateCard({ required this.emoji, required this.name, required this.usage, required this.bg, }); final String emoji; final String name; final String usage; final Color bg; @override Widget build(BuildContext context) { final theme = Theme.of(context); return Material( color: theme.colorScheme.surface, borderRadius: AppRadius.mdBorder, child: InkWell( onTap: () => context.push('/templates'), borderRadius: AppRadius.mdBorder, child: Container( decoration: BoxDecoration( borderRadius: AppRadius.mdBorder, border: Border.all(color: theme.colorScheme.outlineVariant), ), padding: const EdgeInsets.all(DesignTokens.spacing12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Container( height: 96, decoration: BoxDecoration( color: bg, borderRadius: AppRadius.smBorder, ), alignment: Alignment.center, child: Text(emoji, style: const TextStyle(fontSize: 32)), ), const SizedBox(height: DesignTokens.spacing8), Text( name, style: TextStyle( fontFamily: AppTypography.displayFont, fontSize: 14, fontWeight: FontWeight.w600, color: theme.colorScheme.onSurface, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), const SizedBox(height: 2), Text( usage, style: TextStyle( fontSize: 11, color: theme.colorScheme.onSurfaceVariant), ), ], ), ), ), ); } } /// 5. 达人日记(纵向列表) class _ExpertDiariesList extends StatelessWidget { const _ExpertDiariesList({required this.diaries}); final List diaries; @override Widget build(BuildContext context) { final theme = Theme.of(context); return Column( children: diaries.map((diary) { return Container( margin: const EdgeInsets.only(bottom: DesignTokens.spacing12), padding: const EdgeInsets.all(DesignTokens.spacing16), decoration: BoxDecoration( color: theme.colorScheme.surface, borderRadius: AppRadius.mdBorder, border: Border.all(color: theme.colorScheme.outlineVariant), boxShadow: AppShadows.soft(context), ), child: Row( children: [ Container( width: 44, height: 44, decoration: BoxDecoration( color: AppColors.surfaceWarmLight, shape: BoxShape.circle, ), alignment: Alignment.center, child: Text(diary.authorEmoji, style: const TextStyle(fontSize: 20)), ), const SizedBox(width: DesignTokens.spacing12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Text( diary.authorName, style: TextStyle( fontSize: 12, fontWeight: FontWeight.w600, color: theme.colorScheme.onSurfaceVariant, ), ), const SizedBox(width: DesignTokens.spacing8), Text( '·', style: TextStyle( color: theme.colorScheme.onSurfaceVariant), ), const SizedBox(width: DesignTokens.spacing8), Expanded( child: Text( diary.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( diary.contentPreview.isNotEmpty ? diary.contentPreview : '...', maxLines: 2, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 13, color: theme.colorScheme.onSurfaceVariant, height: 1.5, ), ), ], ), ), const SizedBox(width: DesignTokens.spacing8), Row( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.favorite_rounded, size: 14, color: AppColors.rose), const SizedBox(width: 4), Text( diary.likeText, style: TextStyle( fontSize: 11, color: theme.colorScheme.onSurfaceVariant), ), ], ), ], ), ); }).toList(), ); } }