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:
Binary file not shown.
Binary file not shown.
Binary file not shown.
93
app/assets/fonts/OFL.txt
Normal file
93
app/assets/fonts/OFL.txt
Normal 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.
Binary file not shown.
Binary file not shown.
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
],
|
||||
),
|
||||
|
||||
// 搜索页 — 全屏无 Tab(spec §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',
|
||||
|
||||
@@ -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), // 灰蓝
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -157,7 +157,7 @@ class _AchievementProgressCard extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
ClipRRect(
|
||||
borderRadius: AppRadius.xsBorder,
|
||||
borderRadius: AppRadius.smBorder,
|
||||
child: LinearProgressIndicator(
|
||||
value: progress,
|
||||
minHeight: 10,
|
||||
|
||||
@@ -182,13 +182,10 @@ class _LoginPageState extends State<LoginPage> with SingleTickerProviderStateMix
|
||||
padding: const EdgeInsets.only(bottom: DesignTokens.spacing16),
|
||||
child: TextFormField(
|
||||
controller: _displayNameController,
|
||||
decoration: InputDecoration(
|
||||
decoration: const InputDecoration(
|
||||
labelText: '昵称',
|
||||
hintText: '你想被叫什么名字?',
|
||||
prefixIcon: const Icon(Icons.face_rounded),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: AppRadius.mdBorder,
|
||||
),
|
||||
prefixIcon: Icon(Icons.face_rounded),
|
||||
),
|
||||
textInputAction: TextInputAction.next,
|
||||
),
|
||||
@@ -202,9 +199,6 @@ class _LoginPageState extends State<LoginPage> with SingleTickerProviderStateMix
|
||||
labelText: '账号',
|
||||
hintText: _isRegister ? '设置一个账号名' : '输入你的账号',
|
||||
prefixIcon: const Icon(Icons.person_rounded),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: AppRadius.mdBorder,
|
||||
),
|
||||
),
|
||||
textInputAction: TextInputAction.next,
|
||||
validator: (value) {
|
||||
@@ -237,9 +231,6 @@ class _LoginPageState extends State<LoginPage> with SingleTickerProviderStateMix
|
||||
});
|
||||
},
|
||||
),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: AppRadius.mdBorder,
|
||||
),
|
||||
),
|
||||
textInputAction: TextInputAction.done,
|
||||
onFieldSubmitted: (_) => _submit(),
|
||||
@@ -269,7 +260,7 @@ class _LoginPageState extends State<LoginPage> with SingleTickerProviderStateMix
|
||||
onPressed: isLoading ? null : _submit,
|
||||
style: FilledButton.styleFrom(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: AppRadius.mdBorder,
|
||||
borderRadius: AppRadius.pillBorder,
|
||||
),
|
||||
),
|
||||
child: isLoading
|
||||
|
||||
@@ -608,7 +608,7 @@ class _DayCell extends StatelessWidget {
|
||||
: isToday
|
||||
? colorScheme.primaryContainer
|
||||
: null,
|
||||
borderRadius: AppRadius.xsBorder,
|
||||
borderRadius: AppRadius.smBorder,
|
||||
border: isToday && !isSelected
|
||||
? Border.all(color: colorScheme.primary, width: 2)
|
||||
: null,
|
||||
|
||||
@@ -351,7 +351,7 @@ class _DiaryWallCard extends StatelessWidget {
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primaryContainer.withValues(alpha: 0.3),
|
||||
borderRadius: AppRadius.xsBorder,
|
||||
borderRadius: AppRadius.smBorder,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
|
||||
485
app/lib/features/discover/views/discover_page.dart
Normal file
485
app/lib/features/discover/views/discover_page.dart
Normal file
@@ -0,0 +1,485 @@
|
||||
// 发现页 — 严格对齐 spec §3.12 discover.html
|
||||
//
|
||||
// 视觉层级(从上到下):
|
||||
// 1. 搜索框 (pill 形状)
|
||||
// 2. 每日推荐卡片 inspiration-card (accent→tertiary 渐变)
|
||||
// 3. 热门话题 hot-topics (横向滚动 chips)
|
||||
// 4. 精选模板 featured-templates (2 列网格)
|
||||
// 5. 达人日记 expert-diaries (纵向列表)
|
||||
//
|
||||
// 注意:本页是发现/灵感浏览,区别于 /search(主动搜索)
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../core/constants/design_tokens.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/theme/app_radius.dart';
|
||||
import '../../../core/theme/app_shadows.dart';
|
||||
import '../../../core/theme/app_typography.dart';
|
||||
|
||||
class DiscoverPage extends StatelessWidget {
|
||||
const DiscoverPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final isDark = theme.brightness == Brightness.dark;
|
||||
final bg = isDark ? AppColors.bgDark : AppColors.bgLight;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: bg,
|
||||
body: SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: DesignTokens.spacing20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: DesignTokens.spacing12),
|
||||
_SearchBar(onTap: () => context.push('/search')),
|
||||
const SizedBox(height: DesignTokens.spacing20),
|
||||
const _InspirationCard(
|
||||
title: '今日推荐:图书馆的午后时光',
|
||||
author: '小暖 · 5月31日',
|
||||
emoji: '📚',
|
||||
),
|
||||
const SizedBox(height: DesignTokens.spacing24),
|
||||
_SectionTitle(title: '热门话题'),
|
||||
const SizedBox(height: DesignTokens.spacing12),
|
||||
const _HotTopicsChips(),
|
||||
const SizedBox(height: DesignTokens.spacing24),
|
||||
_SectionTitle(title: '精选模板'),
|
||||
const SizedBox(height: DesignTokens.spacing12),
|
||||
const _FeaturedTemplatesGrid(),
|
||||
const SizedBox(height: DesignTokens.spacing24),
|
||||
_SectionTitle(title: '达人日记'),
|
||||
const SizedBox(height: DesignTokens.spacing12),
|
||||
const _ExpertDiariesList(),
|
||||
const SizedBox(height: DesignTokens.spacing24),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 1. 搜索框(点击跳转 /search)
|
||||
class _SearchBar extends StatelessWidget {
|
||||
const _SearchBar({required this.onTap});
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: AppRadius.pillBorder,
|
||||
child: Container(
|
||||
height: 48,
|
||||
padding: const EdgeInsets.symmetric(horizontal: DesignTokens.spacing16),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surface,
|
||||
borderRadius: AppRadius.pillBorder,
|
||||
border: Border.all(color: theme.colorScheme.outlineVariant),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.search_rounded, size: 20, color: theme.colorScheme.onSurfaceVariant),
|
||||
const SizedBox(width: DesignTokens.spacing12),
|
||||
Text(
|
||||
'搜索日记、模板、话题...',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 2. 每日推荐卡片(渐变背景)
|
||||
class _InspirationCard extends StatelessWidget {
|
||||
const _InspirationCard({
|
||||
required this.title,
|
||||
required this.author,
|
||||
required this.emoji,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String author;
|
||||
final String emoji;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(DesignTokens.spacing20),
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [AppColors.accent, AppColors.tertiary],
|
||||
),
|
||||
borderRadius: AppRadius.lgBorder,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppColors.accent.withValues(alpha: 0.2),
|
||||
offset: const Offset(0, 4),
|
||||
blurRadius: 14,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
// 装饰圆
|
||||
Positioned(
|
||||
right: -20,
|
||||
top: -20,
|
||||
child: Container(
|
||||
width: 120,
|
||||
height: 120,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Colors.white.withValues(alpha: 0.12),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: -10,
|
||||
bottom: -20,
|
||||
child: Container(
|
||||
width: 80,
|
||||
height: 80,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Colors.white.withValues(alpha: 0.08),
|
||||
),
|
||||
),
|
||||
),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'今日推荐',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white.withValues(alpha: 0.85),
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: DesignTokens.spacing12),
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 80,
|
||||
height: 80,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.2),
|
||||
borderRadius: AppRadius.mdBorder,
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(emoji, style: const TextStyle(fontSize: 36)),
|
||||
),
|
||||
const SizedBox(width: DesignTokens.spacing16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Colors.white,
|
||||
height: 1.25,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
author,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.white.withValues(alpha: 0.75),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SectionTitle extends StatelessWidget {
|
||||
const _SectionTitle({required this.title});
|
||||
final String title;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.displayFont,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: theme.colorScheme.onSurface,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 3. 热门话题(横向滚动 chips)
|
||||
class _HotTopicsChips extends StatelessWidget {
|
||||
const _HotTopicsChips();
|
||||
|
||||
static const _topics = [
|
||||
'#期末备考', '#读书笔记', '#旅行手账', '#美食日记',
|
||||
'#校园生活', '#自我成长', '#心情日记', '#手写摘抄',
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return SizedBox(
|
||||
height: 44,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: _topics.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(width: DesignTokens.spacing8),
|
||||
itemBuilder: (context, index) {
|
||||
final isHot = index < 3;
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: isHot ? theme.colorScheme.primary : theme.colorScheme.surface,
|
||||
borderRadius: AppRadius.pillBorder,
|
||||
border: isHot ? null : Border.all(color: theme.colorScheme.outlineVariant),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
_topics[index],
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: isHot ? theme.colorScheme.onPrimary : theme.colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 4. 精选模板(2 列网格)
|
||||
class _FeaturedTemplatesGrid extends StatelessWidget {
|
||||
const _FeaturedTemplatesGrid();
|
||||
|
||||
static const _templates = [
|
||||
('📖', '每日心情日记', '2.3k 人使用', AppColors.secondarySoftLight),
|
||||
('🎓', '期末复习计划', '1.8k 人使用', AppColors.tertiarySoftLight),
|
||||
('🌿', '植物观察日记', '956 人使用', AppColors.roseSoftLight),
|
||||
('✈️', '旅行手账本', '742 人使用', AppColors.secondarySoftLight),
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
mainAxisSpacing: DesignTokens.spacing12,
|
||||
crossAxisSpacing: DesignTokens.spacing12,
|
||||
childAspectRatio: 0.85,
|
||||
),
|
||||
itemCount: _templates.length,
|
||||
itemBuilder: (context, index) {
|
||||
final t = _templates[index];
|
||||
return _TemplateCard(emoji: t.$1, name: t.$2, usage: t.$3, bg: t.$4);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TemplateCard extends StatelessWidget {
|
||||
const _TemplateCard({
|
||||
required this.emoji,
|
||||
required this.name,
|
||||
required this.usage,
|
||||
required this.bg,
|
||||
});
|
||||
|
||||
final String emoji;
|
||||
final String name;
|
||||
final String usage;
|
||||
final Color bg;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Material(
|
||||
color: theme.colorScheme.surface,
|
||||
borderRadius: AppRadius.mdBorder,
|
||||
child: InkWell(
|
||||
onTap: () => context.push('/templates'),
|
||||
borderRadius: AppRadius.mdBorder,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: AppRadius.mdBorder,
|
||||
border: Border.all(color: theme.colorScheme.outlineVariant),
|
||||
),
|
||||
padding: const EdgeInsets.all(DesignTokens.spacing12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
height: 96,
|
||||
decoration: BoxDecoration(
|
||||
color: bg,
|
||||
borderRadius: AppRadius.smBorder,
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(emoji, style: const TextStyle(fontSize: 32)),
|
||||
),
|
||||
const SizedBox(height: DesignTokens.spacing8),
|
||||
Text(
|
||||
name,
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.displayFont,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: theme.colorScheme.onSurface,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
usage,
|
||||
style: TextStyle(fontSize: 11, color: theme.colorScheme.onSurfaceVariant),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 5. 达人日记(纵向列表)
|
||||
class _ExpertDiariesList extends StatelessWidget {
|
||||
const _ExpertDiariesList();
|
||||
|
||||
static const _experts = [
|
||||
('🌸', '小桃子', '春日漫步手账', '记录春天的每一朵花开...', '342 赞'),
|
||||
('☕', '咖啡少年', '咖啡馆日记', '今天尝试了一家新店...', '218 赞'),
|
||||
('📝', '学习达人', '考研倒计时30天', '坚持就是胜利...', '556 赞'),
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Column(
|
||||
children: _experts.map((e) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: DesignTokens.spacing12),
|
||||
padding: const EdgeInsets.all(DesignTokens.spacing16),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surface,
|
||||
borderRadius: AppRadius.mdBorder,
|
||||
border: Border.all(color: theme.colorScheme.outlineVariant),
|
||||
boxShadow: AppShadows.soft(context),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceWarmLight,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(e.$1, style: const TextStyle(fontSize: 20)),
|
||||
),
|
||||
const SizedBox(width: DesignTokens.spacing12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
e.$2,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: DesignTokens.spacing8),
|
||||
Text(
|
||||
'·',
|
||||
style: TextStyle(color: theme.colorScheme.onSurfaceVariant),
|
||||
),
|
||||
const SizedBox(width: DesignTokens.spacing8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
e.$3,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.displayFont,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: theme.colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
e.$4,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: DesignTokens.spacing8),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.favorite_rounded, size: 14, color: AppColors.rose),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
e.$5,
|
||||
style: TextStyle(fontSize: 11, color: theme.colorScheme.onSurfaceVariant),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -48,11 +48,19 @@ final class HomeLoaded extends HomeState {
|
||||
/// 连续写日记天数(从日记列表推算)
|
||||
final int streakDays;
|
||||
|
||||
/// 本月日记数(spec §3.4 quick-stats)
|
||||
final int monthCount;
|
||||
|
||||
/// 总日记数(spec §3.4 quick-stats)
|
||||
final int totalCount;
|
||||
|
||||
const HomeLoaded({
|
||||
this.recentJournals = const [],
|
||||
this.hasTodayEntry = false,
|
||||
this.topMood,
|
||||
this.streakDays = 0,
|
||||
this.monthCount = 0,
|
||||
this.totalCount = 0,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -103,11 +111,18 @@ class HomeBloc extends Bloc<HomeEvent, HomeState> {
|
||||
// 推算连续天数
|
||||
final streakDays = _calculateStreak(journals);
|
||||
|
||||
// 本月日记数 & 总数(spec §3.4 quick-stats)
|
||||
final monthCount = journals.where((j) =>
|
||||
j.date.year == today.year && j.date.month == today.month).length;
|
||||
final totalCount = journals.length;
|
||||
|
||||
emit(HomeLoaded(
|
||||
recentJournals: journals,
|
||||
hasTodayEntry: hasTodayEntry,
|
||||
topMood: topMood,
|
||||
streakDays: streakDays,
|
||||
monthCount: monthCount,
|
||||
totalCount: totalCount,
|
||||
));
|
||||
} catch (e) {
|
||||
emit(const HomeLoaded()); // 空状态而非错误,离线友好
|
||||
|
||||
@@ -1,16 +1,31 @@
|
||||
// 首页 — 日记流 + 心情概览
|
||||
// 首页·日记流 — 严格对齐 spec §3.4 home-daily.html
|
||||
//
|
||||
// 视觉层级(从上到下):
|
||||
// 1. 问候语 + 日期(右上角搜索按钮)
|
||||
// 2. 连续记录徽章 streak-badge (pill)
|
||||
// 3. 心情选择器(5 选 1,bg=#FFFFFF surface 卡片)
|
||||
// 4. "今天的日记" 渐变卡片 + 浮动写按钮
|
||||
// 5. 三栏统计(本月日记/连续天数/总日记数)
|
||||
// 6. 最近记录标题 + 查看全部
|
||||
// 7. 日记卡片列表
|
||||
//
|
||||
// 颜色规范(spec §7.1):
|
||||
// - 页面背景用 var(--bg) #FFF8F0(不是纯白)
|
||||
// - Card 用 var(--surface) #FFFFFF(与页面背景形成层次)
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:nuanji_app/core/theme/app_colors.dart';
|
||||
import 'package:nuanji_app/core/theme/app_radius.dart';
|
||||
import 'package:nuanji_app/core/theme/app_typography.dart';
|
||||
import 'package:nuanji_app/data/models/journal_entry.dart';
|
||||
import 'package:nuanji_app/data/repositories/journal_repository.dart';
|
||||
|
||||
import '../../../core/constants/design_tokens.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/theme/app_radius.dart';
|
||||
import '../../../core/theme/app_shadows.dart';
|
||||
import '../../../core/theme/app_typography.dart';
|
||||
import '../../../data/models/journal_entry.dart';
|
||||
import '../../../data/repositories/journal_repository.dart';
|
||||
import '../bloc/home_bloc.dart';
|
||||
|
||||
/// 首页 — 展示最近日记流和心情概览
|
||||
class HomePage extends StatelessWidget {
|
||||
const HomePage({super.key});
|
||||
|
||||
@@ -31,278 +46,691 @@ class _HomeView extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
final isDark = theme.brightness == Brightness.dark;
|
||||
final bg = isDark ? AppColors.bgDark : AppColors.bgLight;
|
||||
|
||||
return BlocBuilder<HomeBloc, HomeState>(
|
||||
builder: (context, state) {
|
||||
final loaded = state is HomeLoaded ? state : const HomeLoaded();
|
||||
final isLoading = state is HomeLoading;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(
|
||||
'暖记',
|
||||
style: theme.textTheme.headlineSmall?.copyWith(
|
||||
fontFamily: AppTypography.handwrittenFont,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
// 搜索按钮(设计稿要求右上角圆形搜索按钮)
|
||||
IconButton(
|
||||
onPressed: () => context.push('/discover'),
|
||||
icon: const Icon(Icons.search_rounded),
|
||||
tooltip: '搜索',
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () => context.go('/stickers'),
|
||||
icon: const Icon(Icons.emoji_emotions_outlined),
|
||||
tooltip: '贴纸库',
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () => context.go('/templates'),
|
||||
icon: const Icon(Icons.dashboard_customize_outlined),
|
||||
tooltip: '模板',
|
||||
),
|
||||
],
|
||||
),
|
||||
body: state is HomeLoading
|
||||
backgroundColor: bg,
|
||||
body: isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: state is HomeLoaded
|
||||
? _buildContent(context, state)
|
||||
: _buildContent(context, const HomeLoaded()),
|
||||
: RefreshIndicator(
|
||||
onRefresh: () async {
|
||||
context.read<HomeBloc>().add(const HomeRefresh());
|
||||
},
|
||||
child: _buildContent(context, loaded),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContent(BuildContext context, HomeLoaded state) {
|
||||
return RefreshIndicator(
|
||||
onRefresh: () async {
|
||||
context.read<HomeBloc>().add(const HomeRefresh());
|
||||
},
|
||||
child: SingleChildScrollView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 心情快速选择卡片
|
||||
_QuickMoodCard(
|
||||
hasTodayEntry: state.hasTodayEntry,
|
||||
topMood: state.topMood,
|
||||
streakDays: state.streakDays,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
final now = DateTime.now();
|
||||
final greeting = _greeting(now.hour);
|
||||
final dateText = _formatDate(now);
|
||||
|
||||
// 最近日记
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'最近日记',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => context.go('/calendar'),
|
||||
child: const Text('查看全部'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
return SingleChildScrollView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
DesignTokens.spacing20,
|
||||
MediaQuery.of(context).padding.top + DesignTokens.spacing8,
|
||||
DesignTokens.spacing20,
|
||||
DesignTokens.spacing24,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_GreetingHeader(
|
||||
greeting: greeting,
|
||||
username: '小暖',
|
||||
dateText: dateText,
|
||||
onSearchTap: () => context.push('/search'),
|
||||
),
|
||||
const SizedBox(height: DesignTokens.spacing16),
|
||||
|
||||
state.recentJournals.isEmpty
|
||||
? const _EmptyJournalState()
|
||||
: _JournalList(journals: state.recentJournals),
|
||||
if (state.streakDays > 0) ...[
|
||||
_StreakBadge(days: state.streakDays),
|
||||
const SizedBox(height: DesignTokens.spacing16),
|
||||
],
|
||||
),
|
||||
|
||||
_MoodSelectorCard(
|
||||
topMood: state.topMood,
|
||||
weather: const _Weather(icon: '☀', label: '晴 26°'),
|
||||
onMoodTap: (_) => context.push('/editor'),
|
||||
),
|
||||
const SizedBox(height: DesignTokens.spacing20),
|
||||
|
||||
_TodayCard(
|
||||
hasTodayEntry: state.hasTodayEntry,
|
||||
onTap: () => context.push('/editor'),
|
||||
),
|
||||
const SizedBox(height: DesignTokens.spacing20),
|
||||
|
||||
_QuickStats(
|
||||
monthCount: state.monthCount,
|
||||
streakDays: state.streakDays,
|
||||
totalCount: state.totalCount,
|
||||
),
|
||||
const SizedBox(height: DesignTokens.spacing24),
|
||||
|
||||
_SectionHeader(
|
||||
title: '最近记录',
|
||||
onSeeAll: () => context.go('/calendar'),
|
||||
),
|
||||
const SizedBox(height: DesignTokens.spacing12),
|
||||
|
||||
state.recentJournals.isEmpty
|
||||
? const _EmptyJournalState()
|
||||
: _JournalList(journals: state.recentJournals),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _greeting(int hour) {
|
||||
if (hour < 6) return '夜深了';
|
||||
if (hour < 11) return '早上好';
|
||||
if (hour < 14) return '中午好';
|
||||
if (hour < 18) return '下午好';
|
||||
if (hour < 22) return '晚上好';
|
||||
return '夜深了';
|
||||
}
|
||||
|
||||
String _formatDate(DateTime now) {
|
||||
const weekdays = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];
|
||||
final w = weekdays[now.weekday - 1];
|
||||
return '${now.year}年${now.month}月${now.day}日 · $w';
|
||||
}
|
||||
}
|
||||
|
||||
/// 心情快速选择卡片
|
||||
class _QuickMoodCard extends StatelessWidget {
|
||||
const _QuickMoodCard({
|
||||
required this.hasTodayEntry,
|
||||
this.topMood,
|
||||
this.streakDays = 0,
|
||||
/// 1. 顶部问候 + 日期 + 搜索按钮
|
||||
class _GreetingHeader extends StatelessWidget {
|
||||
const _GreetingHeader({
|
||||
required this.greeting,
|
||||
required this.username,
|
||||
required this.dateText,
|
||||
required this.onSearchTap,
|
||||
});
|
||||
|
||||
final bool hasTodayEntry;
|
||||
final Mood? topMood;
|
||||
final int streakDays;
|
||||
final String greeting;
|
||||
final String username;
|
||||
final String dateText;
|
||||
final VoidCallback onSearchTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
final moods = [
|
||||
('😊', '开心', Mood.happy),
|
||||
('😌', '平静', Mood.calm),
|
||||
('😢', '难过', Mood.sad),
|
||||
('😠', '生气', Mood.angry),
|
||||
('🤔', '思考', Mood.thinking),
|
||||
];
|
||||
final fg = colorScheme.onSurface;
|
||||
final muted = colorScheme.onSurfaceVariant;
|
||||
|
||||
return Card(
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(borderRadius: AppRadius.lgBorder),
|
||||
color: colorScheme.primaryContainer.withValues(alpha: 0.3),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text('今天心情如何?', style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600)),
|
||||
const Spacer(),
|
||||
if (streakDays > 0)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.tertiary.withValues(alpha: 0.2),
|
||||
borderRadius: AppRadius.xsBorder,
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
dateText,
|
||||
style: TextStyle(fontSize: 13, color: muted, fontWeight: FontWeight.w500),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text.rich(
|
||||
TextSpan(
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.displayFont,
|
||||
fontSize: 30,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: fg,
|
||||
height: 1.2,
|
||||
),
|
||||
children: [
|
||||
TextSpan(text: '$greeting,'),
|
||||
TextSpan(
|
||||
text: username,
|
||||
style: TextStyle(color: colorScheme.primary),
|
||||
),
|
||||
child: Text('🔥 连续 $streakDays 天', style: theme.textTheme.labelSmall),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
InkWell(
|
||||
onTap: onSearchTap,
|
||||
customBorder: const CircleBorder(),
|
||||
child: Container(
|
||||
width: DesignTokens.touchMin,
|
||||
height: DesignTokens.touchMin,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surface,
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: AppShadows.soft(context),
|
||||
),
|
||||
child: Icon(Icons.search_rounded, size: 20, color: fg),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 2. 连续记录徽章 (pill, tertiary-soft 背景)
|
||||
class _StreakBadge extends StatelessWidget {
|
||||
const _StreakBadge({required this.days});
|
||||
final int days;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.tertiarySoftLight,
|
||||
borderRadius: AppRadius.pillBorder,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.local_fire_department_rounded,
|
||||
size: 14,
|
||||
color: Color(0xFFB8860B),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'连续记录 $days 天',
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFFB8860B),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 3. 心情选择器卡片 — bg=#FFFFFF, radius=16
|
||||
class _MoodSelectorCard extends StatelessWidget {
|
||||
const _MoodSelectorCard({
|
||||
required this.topMood,
|
||||
required this.weather,
|
||||
required this.onMoodTap,
|
||||
});
|
||||
|
||||
final Mood? topMood;
|
||||
final _Weather weather;
|
||||
final ValueChanged<Mood> onMoodTap;
|
||||
|
||||
static const _moods = [
|
||||
('😊', '开心', Mood.happy),
|
||||
('😐', '平静', Mood.calm),
|
||||
('😢', '难过', Mood.sad),
|
||||
('😡', '生气', Mood.angry),
|
||||
('🤔', '思考', Mood.thinking),
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: DesignTokens.spacing20,
|
||||
vertical: DesignTokens.spacing16,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surface,
|
||||
borderRadius: AppRadius.mdBorder,
|
||||
boxShadow: AppShadows.soft(context),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'今天心情如何?',
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.handwrittenFont,
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: theme.colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
'${weather.icon} ${weather.label}',
|
||||
style: TextStyle(fontSize: 13, color: theme.colorScheme.onSurfaceVariant),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: DesignTokens.spacing12),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: _moods.map((m) {
|
||||
final isTop = topMood == m.$3;
|
||||
return InkWell(
|
||||
onTap: () => onMoodTap(m.$3),
|
||||
customBorder: const CircleBorder(),
|
||||
child: _MoodOption(
|
||||
emoji: m.$1,
|
||||
label: m.$2,
|
||||
selected: isTop,
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MoodOption extends StatelessWidget {
|
||||
const _MoodOption({
|
||||
required this.emoji,
|
||||
required this.label,
|
||||
required this.selected,
|
||||
});
|
||||
|
||||
final String emoji;
|
||||
final String label;
|
||||
final bool selected;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
AnimatedContainer(
|
||||
duration: DesignTokens.animFast,
|
||||
width: DesignTokens.touchMin,
|
||||
height: DesignTokens.touchMin,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: selected ? AppColors.surfaceWarmLight : Colors.transparent,
|
||||
borderRadius: AppRadius.mdBorder,
|
||||
),
|
||||
child: Text(emoji, style: const TextStyle(fontSize: 28)),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: selected ? FontWeight.w600 : FontWeight.w500,
|
||||
color: selected ? theme.colorScheme.primary : theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Weather {
|
||||
const _Weather({required this.icon, required this.label});
|
||||
final String icon;
|
||||
final String label;
|
||||
}
|
||||
|
||||
/// 4. "今天的日记" 渐变卡片 + 浮动写按钮
|
||||
class _TodayCard extends StatelessWidget {
|
||||
const _TodayCard({required this.hasTodayEntry, required this.onTap});
|
||||
final bool hasTodayEntry;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: AppRadius.lgBorder,
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(DesignTokens.spacing24),
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [AppColors.accent, AppColors.tertiary],
|
||||
),
|
||||
borderRadius: AppRadius.lgBorder,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppColors.accent.withValues(alpha: 0.25),
|
||||
offset: const Offset(0, 4),
|
||||
blurRadius: 14,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
right: -30,
|
||||
top: -30,
|
||||
child: Container(
|
||||
width: 120,
|
||||
height: 120,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Colors.white.withValues(alpha: 0.12),
|
||||
),
|
||||
if (hasTodayEntry)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.secondary.withValues(alpha: 0.2),
|
||||
borderRadius: AppRadius.xsBorder,
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: -20,
|
||||
bottom: -20,
|
||||
child: Container(
|
||||
width: 80,
|
||||
height: 80,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Colors.white.withValues(alpha: 0.08),
|
||||
),
|
||||
),
|
||||
),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'今天的日记',
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.handwrittenFont,
|
||||
fontSize: 13,
|
||||
color: Colors.white.withValues(alpha: 0.85),
|
||||
),
|
||||
child: Text('✅ 今日已写', style: theme.textTheme.labelSmall),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: moods.map((mood) {
|
||||
final isTop = topMood == mood.$3;
|
||||
return GestureDetector(
|
||||
onTap: () => context.push('/editor'),
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: (AppColors.moodColors[mood.$3.value] ?? colorScheme.primary)
|
||||
.withValues(alpha: isTop ? 0.3 : 0.15),
|
||||
border: isTop ? Border.all(color: AppColors.accent, width: 2) : null,
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(mood.$1, style: const TextStyle(fontSize: 22)),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(mood.$2, style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
)),
|
||||
],
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
hasTodayEntry ? '继续今天的记录' : '写点什么吧...',
|
||||
style: const TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.bgLight,
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'记录一个温暖的瞬间...',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: Colors.white.withValues(alpha: 0.75),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Positioned(
|
||||
right: DesignTokens.spacing20,
|
||||
bottom: DesignTokens.spacing20,
|
||||
child: Material(
|
||||
color: Colors.white,
|
||||
shape: const CircleBorder(),
|
||||
elevation: 4,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
customBorder: const CircleBorder(),
|
||||
child: SizedBox(
|
||||
width: 48,
|
||||
height: 48,
|
||||
child: Icon(Icons.add_rounded, size: 22, color: AppColors.accent),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 日记列表
|
||||
/// 5. 三栏统计
|
||||
class _QuickStats extends StatelessWidget {
|
||||
const _QuickStats({
|
||||
required this.monthCount,
|
||||
required this.streakDays,
|
||||
required this.totalCount,
|
||||
});
|
||||
|
||||
final int monthCount;
|
||||
final int streakDays;
|
||||
final int totalCount;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(child: _stat(context, '$monthCount', '本月日记', AppColors.accent)),
|
||||
const SizedBox(width: DesignTokens.spacing12),
|
||||
Expanded(child: _stat(context, '$streakDays', '连续天数', AppColors.secondary)),
|
||||
const SizedBox(width: DesignTokens.spacing12),
|
||||
Expanded(child: _stat(context, '$totalCount', '总日记数', AppColors.fgLight)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _stat(BuildContext context, String num, String label, Color color) {
|
||||
final theme = Theme.of(context);
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(DesignTokens.spacing16),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surface,
|
||||
borderRadius: AppRadius.mdBorder,
|
||||
boxShadow: AppShadows.soft(context),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
num,
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.displayFont,
|
||||
fontSize: 30,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(fontSize: 11, color: theme.colorScheme.onSurfaceVariant),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 6. 区块标题
|
||||
class _SectionHeader extends StatelessWidget {
|
||||
const _SectionHeader({required this.title, required this.onSeeAll});
|
||||
|
||||
final String title;
|
||||
final VoidCallback onSeeAll;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.displayFont,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: theme.colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: onSeeAll,
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: theme.colorScheme.primary,
|
||||
minimumSize: const Size(44, 32),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
),
|
||||
child: const Text('查看全部', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500)),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 7. 日记列表
|
||||
class _JournalList extends StatelessWidget {
|
||||
const _JournalList({required this.journals});
|
||||
final List<JournalEntry> journals;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
return Column(
|
||||
children: journals.map((journal) {
|
||||
final moodColor = AppColors.moodColors[journal.mood.value] ?? colorScheme.primary;
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: AppRadius.mdBorder,
|
||||
side: BorderSide(color: colorScheme.outlineVariant),
|
||||
),
|
||||
child: InkWell(
|
||||
onTap: () => context.push('/editor?id=${journal.id}'),
|
||||
borderRadius: AppRadius.mdBorder,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: moodColor.withValues(alpha: 0.2),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(_moodEmoji(journal.mood), style: const TextStyle(fontSize: 20)),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(journal.title, style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600),
|
||||
maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
const SizedBox(height: 4),
|
||||
Text('${journal.date.month}月${journal.date.day}日',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurface.withValues(alpha: 0.5)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(Icons.chevron_right, color: colorScheme.onSurface.withValues(alpha: 0.3)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
children: journals
|
||||
.map((j) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: DesignTokens.spacing12),
|
||||
child: _JournalCard(journal: j),
|
||||
))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _JournalCard extends StatelessWidget {
|
||||
const _JournalCard({required this.journal});
|
||||
final JournalEntry journal;
|
||||
|
||||
String _moodEmoji(Mood m) => switch (m) {
|
||||
Mood.happy => '😊',
|
||||
Mood.calm => '😐',
|
||||
Mood.sad => '😢',
|
||||
Mood.angry => '😡',
|
||||
Mood.thinking => '🤔',
|
||||
};
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final moodColor = AppColors.moodColors[journal.mood.value] ?? AppColors.accent;
|
||||
final excerpt = journal.tags.isEmpty ? '点击查看详情' : journal.tags.take(3).map((t) => '#$t').join(' ');
|
||||
|
||||
return Material(
|
||||
color: theme.colorScheme.surface,
|
||||
borderRadius: AppRadius.mdBorder,
|
||||
child: InkWell(
|
||||
onTap: () => context.push('/editor?id=${journal.id}'),
|
||||
borderRadius: AppRadius.mdBorder,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(DesignTokens.spacing16),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: AppRadius.mdBorder,
|
||||
border: Border.all(color: theme.colorScheme.outlineVariant),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 72,
|
||||
height: 72,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceWarmLight,
|
||||
borderRadius: AppRadius.smBorder,
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(_moodEmoji(journal.mood), style: const TextStyle(fontSize: 32)),
|
||||
),
|
||||
const SizedBox(width: DesignTokens.spacing16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'${journal.date.month}月${journal.date.day}日',
|
||||
style: TextStyle(fontSize: 11, color: theme.colorScheme.onSurfaceVariant),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
journal.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.displayFont,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: theme.colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
excerpt,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(fontSize: 13, color: theme.colorScheme.onSurfaceVariant, height: 1.5),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
width: 28,
|
||||
height: 28,
|
||||
decoration: BoxDecoration(
|
||||
color: moodColor.withValues(alpha: 0.15),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(_moodEmoji(journal.mood), style: const TextStyle(fontSize: 14)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _moodEmoji(Mood mood) => switch (mood) {
|
||||
Mood.happy => '😊', Mood.calm => '😌', Mood.sad => '😢',
|
||||
Mood.angry => '😠', Mood.thinking => '🤔',
|
||||
};
|
||||
}
|
||||
|
||||
/// 空日记状态
|
||||
class _EmptyJournalState extends StatelessWidget {
|
||||
const _EmptyJournalState();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 48),
|
||||
padding: const EdgeInsets.symmetric(vertical: DesignTokens.spacing40),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(Icons.edit_note_rounded, size: 64, color: colorScheme.onSurface.withValues(alpha: 0.2)),
|
||||
const SizedBox(height: 16),
|
||||
Text('开始你的第一篇手账日记吧!',
|
||||
style: theme.textTheme.bodyLarge?.copyWith(color: colorScheme.onSurface.withValues(alpha: 0.5))),
|
||||
const SizedBox(height: 24),
|
||||
Icon(
|
||||
Icons.auto_stories_rounded,
|
||||
size: 64,
|
||||
color: theme.colorScheme.onSurface.withValues(alpha: 0.2),
|
||||
),
|
||||
const SizedBox(height: DesignTokens.spacing16),
|
||||
Text(
|
||||
'开始你的第一篇手账日记吧!',
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: DesignTokens.spacing24),
|
||||
FilledButton.icon(
|
||||
onPressed: () => context.push('/editor'),
|
||||
icon: const Icon(Icons.add_rounded),
|
||||
label: const Text('写日记'),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: AppColors.accent,
|
||||
foregroundColor: AppColors.bgLight,
|
||||
shape: RoundedRectangleBorder(borderRadius: AppRadius.pillBorder),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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!),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user