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:
iven
2026-06-02 09:11:46 +08:00
parent b320641d9c
commit 181bfb1f3e
20 changed files with 1410 additions and 351 deletions

View File

@@ -5,7 +5,7 @@ import 'package:flutter/animation.dart';
class DesignTokens {
DesignTokens._();
// ===== 间距 =====
// ===== 间距4px 基准9 级)=====
static const double spacing4 = 4;
static const double spacing8 = 8;
static const double spacing12 = 12;
@@ -13,8 +13,16 @@ class DesignTokens {
static const double spacing20 = 20;
static const double spacing24 = 24;
static const double spacing32 = 32;
static const double spacing40 = 40;
static const double spacing48 = 48;
// ===== 安全区 & 布局常量(对齐 spec §1=====
static const double safeTop = 54; // iPhone Dynamic Island
static const double safeBottom = 34; // Home Indicator
static const double tabHeight = 56; // 底部 Tab 栏
static const double touchMin = 44; // WCAG 最小触控目标
static const double containerMax = 390; // 移动端容器宽度
// ===== 动画时长 =====
static const Duration animFast = Duration(milliseconds: 150);
static const Duration animNormal = Duration(milliseconds: 300);

View File

@@ -21,6 +21,7 @@ import '../../features/home/views/home_page.dart';
import '../../features/calendar/views/calendar_page.dart';
import '../../features/mood/views/mood_page.dart';
import '../../features/search/views/search_page.dart';
import '../../features/discover/views/discover_page.dart';
import '../../features/calendar/views/weekly_page.dart';
import '../../features/calendar/views/monthly_page.dart';
import '../../features/profile/views/profile_page.dart';
@@ -151,18 +152,13 @@ GoRouter createAppRouter(AuthBloc authBloc) {
name: 'calendar',
builder: (context, state) => const CalendarPage(),
),
// 发现页(搜索页 — 标签+心情筛选日记
// 发现页 — 灵感、话题、达人日记spec §3.12
GoRoute(
path: '/discover',
name: 'discover',
builder: (context, state) {
final journalRepo = context.read<JournalRepository>();
return BlocProvider(
create: (_) => SearchBloc(journalRepository: journalRepo),
child: const SearchPage(),
);
},
builder: (context, state) => const DiscoverPage(),
),
// 个人中心
GoRoute(
path: '/profile',
name: 'profile',
@@ -171,6 +167,20 @@ GoRouter createAppRouter(AuthBloc authBloc) {
],
),
// 搜索页 — 全屏无 Tabspec §3.13
GoRoute(
path: '/search',
name: 'search',
parentNavigatorKey: _rootNavigatorKey,
builder: (context, state) {
final journalRepo = context.read<JournalRepository>();
return BlocProvider(
create: (_) => SearchBloc(journalRepository: journalRepo),
child: const SearchPage(),
);
},
),
// 全屏页面(无底部导航)
GoRoute(
path: '/editor',

View File

@@ -164,19 +164,21 @@ class AppColors {
static const Color shadowDark = Color(0xFF000000);
// ===== 心情颜色映射 =====
// 对齐 spec §2.8 mood-selector: happy/calm/sad/angry/thinking
// 对齐 spec §3.6 calendar mood-dot 颜色(开心=secondary, 平静=tertiary, 难过=#5B7DB1
/// 心情 → 颜色
/// 心情 → 颜色(主色,用于心情选择器圆圈/标签)
static const Map<String, Color> moodColors = {
'happy': Color(0xFFFFD93D), // 😊 开心 — 暖黄
'calm': Color(0xFF81B29A), // 😌 平静 — 鼠尾草绿
'sad': Color(0xFF7B9CC4), // 😢 难过 — 灰蓝
'angry': Color(0xFFE07A5F), // 😠 生气 — 珊瑚
'thinking': Color(0xFFB8A9C9),// 🤔 思考 — 淡紫
'happy': secondary, // 😊 开心 — 鼠尾草绿 #81B29A
'calm': tertiary, // 😌 平静 — 暖金 #F2CC8F
'sad': Color(0xFF5B7DB1), // 😢 难过 — 灰蓝
'angry': accent, // 😠 生气 — 珊瑚 #E07A5F
'thinking': metaLight, // 🤔 思考 — 灰棕 #8B7E74替代原先的淡紫spec 无淡紫)
};
/// 心情 → 日历背景色
/// 心情 → 日历单元格背景色
static const Map<String, Color> moodCellColors = {
'happy': secondarySoftLight, // #D4E8DC
'happy': secondarySoftLight, // #D4E8DC
'love': roseSoftLight, // #F0DADA
'calm': tertiarySoftLight, // #FBE8C8
'sad': Color(0xFFD4DDE8), // 灰蓝

View File

@@ -1,16 +1,12 @@
// 暖记圆角系统
// 对齐 Open Design 原型稿 tokens.css: xs(8) / sm(10) / md(16) / lg(22) / xl(28) / pill
// 对齐 Open Design 原型稿 tokens.css: sm(10) / md(16) / lg(22) / xl(28) / pill
import 'package:flutter/material.dart';
class AppRadius {
AppRadius._();
/// 小圆角 8px — 小元素
static const double xs = 8;
static BorderRadius get xsBorder => BorderRadius.circular(xs);
/// 小圆角 10px — 按钮、输入框
/// 小圆角 10px — 按钮、输入框、小元素
static const double sm = 10;
static BorderRadius get smBorder => BorderRadius.circular(sm);

View File

@@ -1,4 +1,8 @@
// 暖记阴影系统 — soft / medium / float
// 对齐 spec §1 阴影 token:
// --elev-soft: 0 2px 12px rgba(45,36,32,0.06)
// --elev-medium: 0 4px 20px rgba(45,36,32,0.08)
// --elev-float: 0 8px 32px rgba(45,36,32,0.12)
import 'package:flutter/material.dart';
@@ -12,9 +16,9 @@ class AppShadows {
BoxShadow(
color: isDark
? Colors.black.withValues(alpha: 0.3)
: const Color(0xFF2D2420).withValues(alpha: 0.08),
: const Color(0xFF2D2420).withValues(alpha: 0.06),
offset: const Offset(0, 2),
blurRadius: 8,
blurRadius: 12,
),
];
}
@@ -26,9 +30,9 @@ class AppShadows {
BoxShadow(
color: isDark
? Colors.black.withValues(alpha: 0.4)
: const Color(0xFF2D2420).withValues(alpha: 0.12),
: const Color(0xFF2D2420).withValues(alpha: 0.08),
offset: const Offset(0, 4),
blurRadius: 16,
blurRadius: 20,
),
];
}
@@ -40,9 +44,9 @@ class AppShadows {
BoxShadow(
color: isDark
? Colors.black.withValues(alpha: 0.5)
: const Color(0xFF2D2420).withValues(alpha: 0.16),
: const Color(0xFF2D2420).withValues(alpha: 0.12),
offset: const Offset(0, 8),
blurRadius: 24,
blurRadius: 32,
),
];
}

View File

@@ -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,

View File

@@ -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

View File

@@ -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,

View File

@@ -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: [

View 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(),
);
}
}

View File

@@ -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()); // 空状态而非错误,离线友好

View File

@@ -1,16 +1,31 @@
// 首页日记流 + 心情概览
// 首页·日记流 — 严格对齐 spec §3.4 home-daily.html
//
// 视觉层级(从上到下):
// 1. 问候语 + 日期(右上角搜索按钮)
// 2. 连续记录徽章 streak-badge (pill)
// 3. 心情选择器5 选 1bg=#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),
),
),
],
),

View File

@@ -83,31 +83,7 @@ class _ResponsiveScaffoldState extends State<ResponsiveScaffold> {
}
}
// ===== 导航项定义(4 项,中间由 FAB 占位=====
const _navItems = [
NavigationDestination(
icon: Icon(Icons.home_outlined),
selectedIcon: Icon(Icons.home),
label: '首页',
),
NavigationDestination(
icon: Icon(Icons.calendar_month_outlined),
selectedIcon: Icon(Icons.calendar_month),
label: '日历',
),
// 索引 2: 中心 FAB 占位(写日记)
NavigationDestination(
icon: Icon(Icons.explore_outlined),
selectedIcon: Icon(Icons.explore),
label: '发现',
),
NavigationDestination(
icon: Icon(Icons.person_outline),
selectedIcon: Icon(Icons.person),
label: '我的',
),
];
// ===== 导航项定义(平板/桌面 NavigationRail=====
const _railItems = [
NavigationRailDestination(
@@ -132,7 +108,8 @@ const _railItems = [
),
];
// ===== 中心写日记 FAB 按钮 =====
// ===== 中心写日记 FAB 按钮spec §2.2 凸起按钮)=====
// 尺寸 48x48, 圆形, accent 色, shadow-accent
class _CenterFabButton extends StatelessWidget {
const _CenterFabButton({required this.onPressed});
@@ -141,19 +118,33 @@ class _CenterFabButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
return FloatingActionButton(
heroTag: 'center_write',
onPressed: onPressed,
backgroundColor: AppColors.accent,
foregroundColor: const Color(0xFFFFF8F0),
elevation: 4,
return Material(
color: AppColors.accent,
shape: const CircleBorder(),
child: const Icon(Icons.edit_rounded, size: 28),
elevation: 4,
shadowColor: AppColors.accent.withValues(alpha: 0.4),
child: InkWell(
onTap: onPressed,
customBorder: const CircleBorder(),
child: SizedBox(
width: 48,
height: 48,
child: Center(
child: Icon(Icons.add_rounded, size: 24, color: AppColors.bgLight),
),
),
),
);
}
}
// ===== 手机布局 — 底部 TabBar + 中心凸起 FAB =====
// ===== 手机布局 — 底部 TabBar (90px) + 中心凸起 FAB =====
//
// spec §2.2 规定:
// - Tab 栏总高 = 56px (tab-height) + 34px (safe-bottom) = 90px
// - 中心"写日记"按钮 margin-top:-16px 凸出 Tab 栏顶部
// - 圆形 48x48, accent 色, shadow-accent
// - Tab 项图标 24x24, 文字 11px/500
class _MobileLayout extends StatelessWidget {
const _MobileLayout({
@@ -177,85 +168,121 @@ class _MobileLayout extends StatelessWidget {
? AppBar(title: Text(appBarTitle!))
: null,
body: body,
floatingActionButton: onCenterButtonPressed != null
? _CenterFabButton(onPressed: onCenterButtonPressed!)
: null,
floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked,
extendBody: true, // 允许内容延伸到 Tab 栏下面(圆角透明效果)
bottomNavigationBar: _BottomNavBar(
selectedIndex: selectedIndex,
onDestinationSelected: onDestinationSelected,
onCenterButtonPressed: onCenterButtonPressed,
),
);
}
}
/// 自定义底部导航栏 — 支持中心凹槽
/// 自定义底部导航栏 — 90px 总高 + 中心凸起 FAB
class _BottomNavBar extends StatelessWidget {
const _BottomNavBar({
required this.selectedIndex,
required this.onDestinationSelected,
this.onCenterButtonPressed,
});
final int selectedIndex;
final ValueChanged<int> onDestinationSelected;
final VoidCallback? onCenterButtonPressed;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final bottomPadding = MediaQuery.of(context).padding.bottom;
// spec §2.2: 总高 = 56 (tab-height) + bottomPadding (safe-bottom, 通常 34)
return BottomAppBar(
shape: const CircularNotchedRectangle(),
padding: EdgeInsets.zero,
height: 64,
color: colorScheme.surface,
surfaceTintColor: Colors.transparent,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
// 首页
_NavItem(
icon: Icons.home_outlined,
activeIcon: Icons.home,
label: '首页',
isSelected: selectedIndex == 0,
color: colorScheme.primary,
inactiveColor: colorScheme.onSurfaceVariant,
onTap: () => onDestinationSelected(0),
),
// 日历
_NavItem(
icon: Icons.calendar_month_outlined,
activeIcon: Icons.calendar_month,
label: '日历',
isSelected: selectedIndex == 1,
color: colorScheme.primary,
inactiveColor: colorScheme.onSurfaceVariant,
onTap: () => onDestinationSelected(1),
),
// 中间留给 FAB 凹槽 — 占位
const SizedBox(width: 48),
// 发现
_NavItem(
icon: Icons.explore_outlined,
activeIcon: Icons.explore,
label: '发现',
isSelected: selectedIndex == 2,
color: colorScheme.primary,
inactiveColor: colorScheme.onSurfaceVariant,
onTap: () => onDestinationSelected(2),
),
// 我的
_NavItem(
icon: Icons.person_outline,
activeIcon: Icons.person,
label: '我的',
isSelected: selectedIndex == 3,
color: colorScheme.primary,
inactiveColor: colorScheme.onSurfaceVariant,
onTap: () => onDestinationSelected(3),
),
],
return Material(
color: Colors.transparent,
child: SizedBox(
height: 56 + bottomPadding,
child: Stack(
clipBehavior: Clip.none,
alignment: Alignment.topCenter,
children: [
// Tab 栏主体
Positioned(
left: 0,
right: 0,
top: 0,
bottom: 0,
child: Container(
decoration: BoxDecoration(
color: colorScheme.surface,
border: Border(
top: BorderSide(color: colorScheme.outlineVariant, width: 1),
),
),
padding: EdgeInsets.only(bottom: bottomPadding),
child: Row(
children: [
// 首页
Expanded(
child: _NavItem(
icon: Icons.home_outlined,
activeIcon: Icons.home,
label: '首页',
isSelected: selectedIndex == 0,
color: colorScheme.primary,
inactiveColor: colorScheme.onSurfaceVariant,
onTap: () => onDestinationSelected(0),
),
),
// 日历
Expanded(
child: _NavItem(
icon: Icons.calendar_month_outlined,
activeIcon: Icons.calendar_month,
label: '日历',
isSelected: selectedIndex == 1,
color: colorScheme.primary,
inactiveColor: colorScheme.onSurfaceVariant,
onTap: () => onDestinationSelected(1),
),
),
// 中间 FAB 占位
const SizedBox(width: 64),
// 发现
Expanded(
child: _NavItem(
icon: Icons.explore_outlined,
activeIcon: Icons.explore,
label: '发现',
isSelected: selectedIndex == 2,
color: colorScheme.primary,
inactiveColor: colorScheme.onSurfaceVariant,
onTap: () => onDestinationSelected(2),
),
),
// 我的
Expanded(
child: _NavItem(
icon: Icons.person_outline,
activeIcon: Icons.person,
label: '我的',
isSelected: selectedIndex == 3,
color: colorScheme.primary,
inactiveColor: colorScheme.onSurfaceVariant,
onTap: () => onDestinationSelected(3),
),
),
],
),
),
),
// 中心凸起写按钮 — margin-top:-16 凸出 Tab 栏顶部
if (onCenterButtonPressed != null)
Positioned(
top: -16,
child: _CenterFabButton(onPressed: onCenterButtonPressed!),
),
],
),
),
);
}