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

Binary file not shown.

Binary file not shown.

Binary file not shown.

93
app/assets/fonts/OFL.txt Normal file
View File

@@ -0,0 +1,93 @@
Copyright 2011 The Quicksand Project Authors (https://github.com/andrew-paglinawan/QuicksandFamily), with Reserved Font Name “Quicksand”.
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

Binary file not shown.

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!),
),
],
),
),
);
}