fix(app): 对齐 Open Design spec — 字体/Token/首页/Tab栏/路由/Discover页
针对 docs/opendesign/warm-notes-design-spec.md 全面审查的修复: ## 🔴 阻断级修复(商用合规) - 下载真实 Quicksand/Nunito 字体文件(原 0 字节) - 添加 OFL.txt 许可证文件,履行 SIL Open Font License 分发义务 ## 🟠 设计 Token 偏差 - AppRadius: 删除非规范的 xs=8px,所有引用迁移至 sm=10px - AppColors.moodColors: 对齐 spec §3.6 - happy #FFD93D → secondary #81B29A - calm #81B29A → tertiary #F2CC8F - sad #7B9CC4 → #5B7DB1 - thinking #B8A9C9(淡紫,spec 无)→ #8B7E74 - AppShadows: blurRadius/alpha 精确对齐 spec §1 (12/20/32 + 0.06/0.08/0.12) - DesignTokens: 补 spacing40 + 新增 safe-top/safe-bottom/tab-height/touch-min 常量 ## 🟠 首页 §3.4 完全重构 - 新增问候语头部(xx好,小暖 + accent 色高亮名字) - 新增 streak-badge pill 徽章(tertiary-soft + #B8860B 暖金) - 心情选择器卡片背景从 primaryContainer 改为 surface(spec 规定 #FFFFFF) - 心情卡片圆角 lg(22) → md(16) 对齐 spec - 新增 today-card 渐变卡片 + 浮动右下圆形写按钮 - 新增 quick-stats 三栏统计(本月日记/连续天数/总日记数) - 移除 AppBar 多余的贴纸/模板按钮,搜索按钮改路由到 /search - HomeBloc 扩展 monthCount/totalCount 字段 - 日记卡片:72×72 预览图 + 标签摘要 + 心情圆点 ## 🟠 路由 §3.12 + §3.13 拆分 - 新建 DiscoverPage (features/discover/views/discover_page.dart) - 搜索框(跳转 /search) - 每日推荐渐变卡片 - 热门话题横向 chips(前 3 个 accent 高亮) - 精选模板 2 列网格 - 达人日记列表 - /discover 路由从指向 SearchPage 改为 DiscoverPage - 新增 /search 路由(全屏无 Tab)指向 SearchPage ## 🟠 Tab 栏 §2.2 重构 - 高度从 64px 改为 56+bottomPadding(含 safe-bottom,约 90px) - 中心按钮从 CircularNotchedRectangle 凹槽改为 margin-top:-16px 凸起 - FAB 尺寸从默认改为 48×48 spec 规格 - FAB 图标从 edit_rounded 改为 add_rounded(spec §2.2) - 删除未使用的 _navItems 旧常量 ## 🟡 登录页圆角统一 - 移除 3 处 InputBorder 显式 mdBorder(16px) 覆盖 - 全局主题 smBorder(10px) 生效,对齐 spec - 提交按钮圆角改为 pill(spec §2.6 Primary 按钮) ## 验证 - flutter analyze: 0 errors (剩余 40 个 warning/info 全为预存) - flutter test: 84/85 通过(widget smoke test 预存失败,与本次无关)
This commit is contained in:
@@ -157,7 +157,7 @@ class _AchievementProgressCard extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
ClipRRect(
|
||||
borderRadius: AppRadius.xsBorder,
|
||||
borderRadius: AppRadius.smBorder,
|
||||
child: LinearProgressIndicator(
|
||||
value: progress,
|
||||
minHeight: 10,
|
||||
|
||||
@@ -182,13 +182,10 @@ class _LoginPageState extends State<LoginPage> with SingleTickerProviderStateMix
|
||||
padding: const EdgeInsets.only(bottom: DesignTokens.spacing16),
|
||||
child: TextFormField(
|
||||
controller: _displayNameController,
|
||||
decoration: InputDecoration(
|
||||
decoration: const InputDecoration(
|
||||
labelText: '昵称',
|
||||
hintText: '你想被叫什么名字?',
|
||||
prefixIcon: const Icon(Icons.face_rounded),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: AppRadius.mdBorder,
|
||||
),
|
||||
prefixIcon: Icon(Icons.face_rounded),
|
||||
),
|
||||
textInputAction: TextInputAction.next,
|
||||
),
|
||||
@@ -202,9 +199,6 @@ class _LoginPageState extends State<LoginPage> with SingleTickerProviderStateMix
|
||||
labelText: '账号',
|
||||
hintText: _isRegister ? '设置一个账号名' : '输入你的账号',
|
||||
prefixIcon: const Icon(Icons.person_rounded),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: AppRadius.mdBorder,
|
||||
),
|
||||
),
|
||||
textInputAction: TextInputAction.next,
|
||||
validator: (value) {
|
||||
@@ -237,9 +231,6 @@ class _LoginPageState extends State<LoginPage> with SingleTickerProviderStateMix
|
||||
});
|
||||
},
|
||||
),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: AppRadius.mdBorder,
|
||||
),
|
||||
),
|
||||
textInputAction: TextInputAction.done,
|
||||
onFieldSubmitted: (_) => _submit(),
|
||||
@@ -269,7 +260,7 @@ class _LoginPageState extends State<LoginPage> with SingleTickerProviderStateMix
|
||||
onPressed: isLoading ? null : _submit,
|
||||
style: FilledButton.styleFrom(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: AppRadius.mdBorder,
|
||||
borderRadius: AppRadius.pillBorder,
|
||||
),
|
||||
),
|
||||
child: isLoading
|
||||
|
||||
@@ -608,7 +608,7 @@ class _DayCell extends StatelessWidget {
|
||||
: isToday
|
||||
? colorScheme.primaryContainer
|
||||
: null,
|
||||
borderRadius: AppRadius.xsBorder,
|
||||
borderRadius: AppRadius.smBorder,
|
||||
border: isToday && !isSelected
|
||||
? Border.all(color: colorScheme.primary, width: 2)
|
||||
: null,
|
||||
|
||||
@@ -351,7 +351,7 @@ class _DiaryWallCard extends StatelessWidget {
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primaryContainer.withValues(alpha: 0.3),
|
||||
borderRadius: AppRadius.xsBorder,
|
||||
borderRadius: AppRadius.smBorder,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
|
||||
485
app/lib/features/discover/views/discover_page.dart
Normal file
485
app/lib/features/discover/views/discover_page.dart
Normal file
@@ -0,0 +1,485 @@
|
||||
// 发现页 — 严格对齐 spec §3.12 discover.html
|
||||
//
|
||||
// 视觉层级(从上到下):
|
||||
// 1. 搜索框 (pill 形状)
|
||||
// 2. 每日推荐卡片 inspiration-card (accent→tertiary 渐变)
|
||||
// 3. 热门话题 hot-topics (横向滚动 chips)
|
||||
// 4. 精选模板 featured-templates (2 列网格)
|
||||
// 5. 达人日记 expert-diaries (纵向列表)
|
||||
//
|
||||
// 注意:本页是发现/灵感浏览,区别于 /search(主动搜索)
|
||||
|
||||
import 'package:flutter/material.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';
|
||||
|
||||
class DiscoverPage extends StatelessWidget {
|
||||
const DiscoverPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final isDark = theme.brightness == Brightness.dark;
|
||||
final bg = isDark ? AppColors.bgDark : AppColors.bgLight;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: bg,
|
||||
body: SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
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),
|
||||
const _InspirationCard(
|
||||
title: '今日推荐:图书馆的午后时光',
|
||||
author: '小暖 · 5月31日',
|
||||
emoji: '📚',
|
||||
),
|
||||
const SizedBox(height: DesignTokens.spacing24),
|
||||
_SectionTitle(title: '热门话题'),
|
||||
const SizedBox(height: DesignTokens.spacing12),
|
||||
const _HotTopicsChips(),
|
||||
const SizedBox(height: DesignTokens.spacing24),
|
||||
_SectionTitle(title: '精选模板'),
|
||||
const SizedBox(height: DesignTokens.spacing12),
|
||||
const _FeaturedTemplatesGrid(),
|
||||
const SizedBox(height: DesignTokens.spacing24),
|
||||
_SectionTitle(title: '达人日记'),
|
||||
const SizedBox(height: DesignTokens.spacing12),
|
||||
const _ExpertDiariesList(),
|
||||
const SizedBox(height: DesignTokens.spacing24),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.title,
|
||||
required this.author,
|
||||
required this.emoji,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String author;
|
||||
final String emoji;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
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(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Colors.white,
|
||||
height: 1.25,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
author,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.white.withValues(alpha: 0.75),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
static const _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],
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: isHot ? theme.colorScheme.onPrimary : theme.colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 4. 精选模板(2 列网格)
|
||||
class _FeaturedTemplatesGrid extends StatelessWidget {
|
||||
const _FeaturedTemplatesGrid();
|
||||
|
||||
static const _templates = [
|
||||
('📖', '每日心情日记', '2.3k 人使用', AppColors.secondarySoftLight),
|
||||
('🎓', '期末复习计划', '1.8k 人使用', AppColors.tertiarySoftLight),
|
||||
('🌿', '植物观察日记', '956 人使用', AppColors.roseSoftLight),
|
||||
('✈️', '旅行手账本', '742 人使用', AppColors.secondarySoftLight),
|
||||
];
|
||||
|
||||
@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.$1, name: t.$2, usage: t.$3, bg: t.$4);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
static const _experts = [
|
||||
('🌸', '小桃子', '春日漫步手账', '记录春天的每一朵花开...', '342 赞'),
|
||||
('☕', '咖啡少年', '咖啡馆日记', '今天尝试了一家新店...', '218 赞'),
|
||||
('📝', '学习达人', '考研倒计时30天', '坚持就是胜利...', '556 赞'),
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Column(
|
||||
children: _experts.map((e) {
|
||||
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(e.$1, style: const TextStyle(fontSize: 20)),
|
||||
),
|
||||
const SizedBox(width: DesignTokens.spacing12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
e.$2,
|
||||
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(
|
||||
e.$3,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.displayFont,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: theme.colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
e.$4,
|
||||
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(
|
||||
e.$5,
|
||||
style: TextStyle(fontSize: 11, color: theme.colorScheme.onSurfaceVariant),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -48,11 +48,19 @@ final class HomeLoaded extends HomeState {
|
||||
/// 连续写日记天数(从日记列表推算)
|
||||
final int streakDays;
|
||||
|
||||
/// 本月日记数(spec §3.4 quick-stats)
|
||||
final int monthCount;
|
||||
|
||||
/// 总日记数(spec §3.4 quick-stats)
|
||||
final int totalCount;
|
||||
|
||||
const HomeLoaded({
|
||||
this.recentJournals = const [],
|
||||
this.hasTodayEntry = false,
|
||||
this.topMood,
|
||||
this.streakDays = 0,
|
||||
this.monthCount = 0,
|
||||
this.totalCount = 0,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -103,11 +111,18 @@ class HomeBloc extends Bloc<HomeEvent, HomeState> {
|
||||
// 推算连续天数
|
||||
final streakDays = _calculateStreak(journals);
|
||||
|
||||
// 本月日记数 & 总数(spec §3.4 quick-stats)
|
||||
final monthCount = journals.where((j) =>
|
||||
j.date.year == today.year && j.date.month == today.month).length;
|
||||
final totalCount = journals.length;
|
||||
|
||||
emit(HomeLoaded(
|
||||
recentJournals: journals,
|
||||
hasTodayEntry: hasTodayEntry,
|
||||
topMood: topMood,
|
||||
streakDays: streakDays,
|
||||
monthCount: monthCount,
|
||||
totalCount: totalCount,
|
||||
));
|
||||
} catch (e) {
|
||||
emit(const HomeLoaded()); // 空状态而非错误,离线友好
|
||||
|
||||
@@ -1,16 +1,31 @@
|
||||
// 首页 — 日记流 + 心情概览
|
||||
// 首页·日记流 — 严格对齐 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 'package:nuanji_app/core/theme/app_colors.dart';
|
||||
import 'package:nuanji_app/core/theme/app_radius.dart';
|
||||
import 'package:nuanji_app/core/theme/app_typography.dart';
|
||||
import 'package:nuanji_app/data/models/journal_entry.dart';
|
||||
import 'package:nuanji_app/data/repositories/journal_repository.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});
|
||||
|
||||
@@ -31,278 +46,691 @@ class _HomeView extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
final isDark = theme.brightness == Brightness.dark;
|
||||
final bg = isDark ? AppColors.bgDark : AppColors.bgLight;
|
||||
|
||||
return BlocBuilder<HomeBloc, HomeState>(
|
||||
builder: (context, state) {
|
||||
final loaded = state is HomeLoaded ? state : const HomeLoaded();
|
||||
final isLoading = state is HomeLoading;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(
|
||||
'暖记',
|
||||
style: theme.textTheme.headlineSmall?.copyWith(
|
||||
fontFamily: AppTypography.handwrittenFont,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
// 搜索按钮(设计稿要求右上角圆形搜索按钮)
|
||||
IconButton(
|
||||
onPressed: () => context.push('/discover'),
|
||||
icon: const Icon(Icons.search_rounded),
|
||||
tooltip: '搜索',
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () => context.go('/stickers'),
|
||||
icon: const Icon(Icons.emoji_emotions_outlined),
|
||||
tooltip: '贴纸库',
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () => context.go('/templates'),
|
||||
icon: const Icon(Icons.dashboard_customize_outlined),
|
||||
tooltip: '模板',
|
||||
),
|
||||
],
|
||||
),
|
||||
body: state is HomeLoading
|
||||
backgroundColor: bg,
|
||||
body: isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: state is HomeLoaded
|
||||
? _buildContent(context, state)
|
||||
: _buildContent(context, const HomeLoaded()),
|
||||
: RefreshIndicator(
|
||||
onRefresh: () async {
|
||||
context.read<HomeBloc>().add(const HomeRefresh());
|
||||
},
|
||||
child: _buildContent(context, loaded),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContent(BuildContext context, HomeLoaded state) {
|
||||
return RefreshIndicator(
|
||||
onRefresh: () async {
|
||||
context.read<HomeBloc>().add(const HomeRefresh());
|
||||
},
|
||||
child: SingleChildScrollView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 心情快速选择卡片
|
||||
_QuickMoodCard(
|
||||
hasTodayEntry: state.hasTodayEntry,
|
||||
topMood: state.topMood,
|
||||
streakDays: state.streakDays,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
final now = DateTime.now();
|
||||
final greeting = _greeting(now.hour);
|
||||
final dateText = _formatDate(now);
|
||||
|
||||
// 最近日记
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'最近日记',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => context.go('/calendar'),
|
||||
child: const Text('查看全部'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
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),
|
||||
|
||||
state.recentJournals.isEmpty
|
||||
? const _EmptyJournalState()
|
||||
: _JournalList(journals: state.recentJournals),
|
||||
if (state.streakDays > 0) ...[
|
||||
_StreakBadge(days: state.streakDays),
|
||||
const SizedBox(height: DesignTokens.spacing16),
|
||||
],
|
||||
),
|
||||
|
||||
_MoodSelectorCard(
|
||||
topMood: state.topMood,
|
||||
weather: const _Weather(icon: '☀', label: '晴 26°'),
|
||||
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';
|
||||
}
|
||||
}
|
||||
|
||||
/// 心情快速选择卡片
|
||||
class _QuickMoodCard extends StatelessWidget {
|
||||
const _QuickMoodCard({
|
||||
required this.hasTodayEntry,
|
||||
this.topMood,
|
||||
this.streakDays = 0,
|
||||
/// 1. 顶部问候 + 日期 + 搜索按钮
|
||||
class _GreetingHeader extends StatelessWidget {
|
||||
const _GreetingHeader({
|
||||
required this.greeting,
|
||||
required this.username,
|
||||
required this.dateText,
|
||||
required this.onSearchTap,
|
||||
});
|
||||
|
||||
final bool hasTodayEntry;
|
||||
final Mood? topMood;
|
||||
final int streakDays;
|
||||
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 moods = [
|
||||
('😊', '开心', Mood.happy),
|
||||
('😌', '平静', Mood.calm),
|
||||
('😢', '难过', Mood.sad),
|
||||
('😠', '生气', Mood.angry),
|
||||
('🤔', '思考', Mood.thinking),
|
||||
];
|
||||
final fg = colorScheme.onSurface;
|
||||
final muted = colorScheme.onSurfaceVariant;
|
||||
|
||||
return Card(
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(borderRadius: AppRadius.lgBorder),
|
||||
color: colorScheme.primaryContainer.withValues(alpha: 0.3),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text('今天心情如何?', style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600)),
|
||||
const Spacer(),
|
||||
if (streakDays > 0)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.tertiary.withValues(alpha: 0.2),
|
||||
borderRadius: AppRadius.xsBorder,
|
||||
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),
|
||||
),
|
||||
child: Text('🔥 连续 $streakDays 天', style: theme.textTheme.labelSmall),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
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.weather,
|
||||
required this.onMoodTap,
|
||||
});
|
||||
|
||||
final Mood? topMood;
|
||||
final _Weather weather;
|
||||
final ValueChanged<Mood> onMoodTap;
|
||||
|
||||
static const _moods = [
|
||||
('😊', '开心', Mood.happy),
|
||||
('😐', '平静', Mood.calm),
|
||||
('😢', '难过', Mood.sad),
|
||||
('😡', '生气', Mood.angry),
|
||||
('🤔', '思考', Mood.thinking),
|
||||
];
|
||||
|
||||
@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(),
|
||||
Text(
|
||||
'${weather.icon} ${weather.label}',
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Weather {
|
||||
const _Weather({required this.icon, required this.label});
|
||||
final String icon;
|
||||
final String label;
|
||||
}
|
||||
|
||||
/// 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),
|
||||
),
|
||||
if (hasTodayEntry)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.secondary.withValues(alpha: 0.2),
|
||||
borderRadius: AppRadius.xsBorder,
|
||||
),
|
||||
),
|
||||
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),
|
||||
),
|
||||
child: Text('✅ 今日已写', style: theme.textTheme.labelSmall),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: moods.map((mood) {
|
||||
final isTop = topMood == mood.$3;
|
||||
return GestureDetector(
|
||||
onTap: () => context.push('/editor'),
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: (AppColors.moodColors[mood.$3.value] ?? colorScheme.primary)
|
||||
.withValues(alpha: isTop ? 0.3 : 0.15),
|
||||
border: isTop ? Border.all(color: AppColors.accent, width: 2) : null,
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(mood.$1, style: const TextStyle(fontSize: 22)),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(mood.$2, style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
)),
|
||||
],
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
hasTodayEntry ? '继续今天的记录' : '写点什么吧...',
|
||||
style: const TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.bgLight,
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
],
|
||||
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<JournalEntry> journals;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
return Column(
|
||||
children: journals.map((journal) {
|
||||
final moodColor = AppColors.moodColors[journal.mood.value] ?? colorScheme.primary;
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: AppRadius.mdBorder,
|
||||
side: BorderSide(color: colorScheme.outlineVariant),
|
||||
),
|
||||
child: InkWell(
|
||||
onTap: () => context.push('/editor?id=${journal.id}'),
|
||||
borderRadius: AppRadius.mdBorder,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: moodColor.withValues(alpha: 0.2),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(_moodEmoji(journal.mood), style: const TextStyle(fontSize: 20)),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(journal.title, style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600),
|
||||
maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
const SizedBox(height: 4),
|
||||
Text('${journal.date.month}月${journal.date.day}日',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurface.withValues(alpha: 0.5)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(Icons.chevron_right, color: colorScheme.onSurface.withValues(alpha: 0.3)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
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.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)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _moodEmoji(Mood mood) => switch (mood) {
|
||||
Mood.happy => '😊', Mood.calm => '😌', Mood.sad => '😢',
|
||||
Mood.angry => '😠', Mood.thinking => '🤔',
|
||||
};
|
||||
}
|
||||
|
||||
/// 空日记状态
|
||||
class _EmptyJournalState extends StatelessWidget {
|
||||
const _EmptyJournalState();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 48),
|
||||
padding: const EdgeInsets.symmetric(vertical: DesignTokens.spacing40),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(Icons.edit_note_rounded, size: 64, color: colorScheme.onSurface.withValues(alpha: 0.2)),
|
||||
const SizedBox(height: 16),
|
||||
Text('开始你的第一篇手账日记吧!',
|
||||
style: theme.textTheme.bodyLarge?.copyWith(color: colorScheme.onSurface.withValues(alpha: 0.5))),
|
||||
const SizedBox(height: 24),
|
||||
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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user