Files
nj/app/lib/features/discover/views/discover_page.dart
iven 346c751cbb
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
refactor(app): 迁移 4 个页面到共享 EmptyStateWidget + ErrorStateWidget
迁移统计:
- discover_page: _buildError → ErrorStateWidget, _buildEmptyHint → EmptyStateWidget
- sticker_library_page: 错误 + 空列表 → 共享组件
- class_page: 错误/班级列表空/日记墙空/话题空 → 共享组件 (4 处)
- calendar_page: CalendarError → ErrorStateWidget

统一体验: 所有页面空状态使用一致的 icon + title + subtitle + CTA 布局
2026-06-07 13:42:56 +08:00

648 lines
21 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 发现页 — 严格对齐 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<DiscoverBloc>().add(const DiscoverRefresh());
// 等待状态变化完成
await context.read<DiscoverBloc>().stream.firstWhere(
(s) => s is DiscoverLoaded || s is DiscoverError,
orElse: () => const DiscoverLoaded(DiscoverData()),
);
},
child: BlocBuilder<DiscoverBloc, DiscoverState>(
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<DiscoverBloc>().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<TagCount> 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<DiscoverTemplateItem> 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<ExpertDiaryItem> 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(),
);
}
}