diff --git a/app/assets/fonts/Nunito-Bold.ttf b/app/assets/fonts/Nunito-Bold.ttf index e69de29..2ec1f4b 100644 Binary files a/app/assets/fonts/Nunito-Bold.ttf and b/app/assets/fonts/Nunito-Bold.ttf differ diff --git a/app/assets/fonts/Nunito-Regular.ttf b/app/assets/fonts/Nunito-Regular.ttf index e69de29..2ec1f4b 100644 Binary files a/app/assets/fonts/Nunito-Regular.ttf and b/app/assets/fonts/Nunito-Regular.ttf differ diff --git a/app/assets/fonts/Nunito-SemiBold.ttf b/app/assets/fonts/Nunito-SemiBold.ttf index e69de29..2ec1f4b 100644 Binary files a/app/assets/fonts/Nunito-SemiBold.ttf and b/app/assets/fonts/Nunito-SemiBold.ttf differ diff --git a/app/assets/fonts/OFL.txt b/app/assets/fonts/OFL.txt new file mode 100644 index 0000000..5186b46 --- /dev/null +++ b/app/assets/fonts/OFL.txt @@ -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. diff --git a/app/assets/fonts/Quicksand-Bold.ttf b/app/assets/fonts/Quicksand-Bold.ttf index e69de29..8cd9133 100644 Binary files a/app/assets/fonts/Quicksand-Bold.ttf and b/app/assets/fonts/Quicksand-Bold.ttf differ diff --git a/app/assets/fonts/Quicksand-Regular.ttf b/app/assets/fonts/Quicksand-Regular.ttf index e69de29..8cd9133 100644 Binary files a/app/assets/fonts/Quicksand-Regular.ttf and b/app/assets/fonts/Quicksand-Regular.ttf differ diff --git a/app/assets/fonts/Quicksand-SemiBold.ttf b/app/assets/fonts/Quicksand-SemiBold.ttf index e69de29..8cd9133 100644 Binary files a/app/assets/fonts/Quicksand-SemiBold.ttf and b/app/assets/fonts/Quicksand-SemiBold.ttf differ diff --git a/app/lib/core/constants/design_tokens.dart b/app/lib/core/constants/design_tokens.dart index 7324f84..40e6c99 100644 --- a/app/lib/core/constants/design_tokens.dart +++ b/app/lib/core/constants/design_tokens.dart @@ -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); diff --git a/app/lib/core/routing/app_router.dart b/app/lib/core/routing/app_router.dart index 416cda4..75cf447 100644 --- a/app/lib/core/routing/app_router.dart +++ b/app/lib/core/routing/app_router.dart @@ -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(); - 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(); + return BlocProvider( + create: (_) => SearchBloc(journalRepository: journalRepo), + child: const SearchPage(), + ); + }, + ), + // 全屏页面(无底部导航) GoRoute( path: '/editor', diff --git a/app/lib/core/theme/app_colors.dart b/app/lib/core/theme/app_colors.dart index c259b8c..f87dc35 100644 --- a/app/lib/core/theme/app_colors.dart +++ b/app/lib/core/theme/app_colors.dart @@ -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 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 moodCellColors = { - 'happy': secondarySoftLight, // #D4E8DC + 'happy': secondarySoftLight, // #D4E8DC 'love': roseSoftLight, // #F0DADA 'calm': tertiarySoftLight, // #FBE8C8 'sad': Color(0xFFD4DDE8), // 灰蓝 diff --git a/app/lib/core/theme/app_radius.dart b/app/lib/core/theme/app_radius.dart index e426020..8dc7ac7 100644 --- a/app/lib/core/theme/app_radius.dart +++ b/app/lib/core/theme/app_radius.dart @@ -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); diff --git a/app/lib/core/theme/app_shadows.dart b/app/lib/core/theme/app_shadows.dart index 04bf686..5c742bc 100644 --- a/app/lib/core/theme/app_shadows.dart +++ b/app/lib/core/theme/app_shadows.dart @@ -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, ), ]; } diff --git a/app/lib/features/achievement/views/achievement_page.dart b/app/lib/features/achievement/views/achievement_page.dart index 429e259..0ac4e32 100644 --- a/app/lib/features/achievement/views/achievement_page.dart +++ b/app/lib/features/achievement/views/achievement_page.dart @@ -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, diff --git a/app/lib/features/auth/views/login_page.dart b/app/lib/features/auth/views/login_page.dart index 3fa784a..01b5273 100644 --- a/app/lib/features/auth/views/login_page.dart +++ b/app/lib/features/auth/views/login_page.dart @@ -182,13 +182,10 @@ class _LoginPageState extends State 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 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 with SingleTickerProviderStateMix }); }, ), - border: OutlineInputBorder( - borderRadius: AppRadius.mdBorder, - ), ), textInputAction: TextInputAction.done, onFieldSubmitted: (_) => _submit(), @@ -269,7 +260,7 @@ class _LoginPageState extends State with SingleTickerProviderStateMix onPressed: isLoading ? null : _submit, style: FilledButton.styleFrom( shape: RoundedRectangleBorder( - borderRadius: AppRadius.mdBorder, + borderRadius: AppRadius.pillBorder, ), ), child: isLoading diff --git a/app/lib/features/calendar/views/calendar_page.dart b/app/lib/features/calendar/views/calendar_page.dart index 643964b..9af5cc3 100644 --- a/app/lib/features/calendar/views/calendar_page.dart +++ b/app/lib/features/calendar/views/calendar_page.dart @@ -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, diff --git a/app/lib/features/class_/views/class_page.dart b/app/lib/features/class_/views/class_page.dart index 4134cbc..9098134 100644 --- a/app/lib/features/class_/views/class_page.dart +++ b/app/lib/features/class_/views/class_page.dart @@ -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: [ diff --git a/app/lib/features/discover/views/discover_page.dart b/app/lib/features/discover/views/discover_page.dart new file mode 100644 index 0000000..421fcee --- /dev/null +++ b/app/lib/features/discover/views/discover_page.dart @@ -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(), + ); + } +} diff --git a/app/lib/features/home/bloc/home_bloc.dart b/app/lib/features/home/bloc/home_bloc.dart index ca96385..3713f02 100644 --- a/app/lib/features/home/bloc/home_bloc.dart +++ b/app/lib/features/home/bloc/home_bloc.dart @@ -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 { // 推算连续天数 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()); // 空状态而非错误,离线友好 diff --git a/app/lib/features/home/views/home_page.dart b/app/lib/features/home/views/home_page.dart index 76a9830..0aca342 100644 --- a/app/lib/features/home/views/home_page.dart +++ b/app/lib/features/home/views/home_page.dart @@ -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( 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().add(const HomeRefresh()); + }, + child: _buildContent(context, loaded), + ), ); }, ); } Widget _buildContent(BuildContext context, HomeLoaded state) { - return RefreshIndicator( - onRefresh: () async { - context.read().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 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 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), + ), ), ], ), diff --git a/app/lib/widgets/responsive_scaffold.dart b/app/lib/widgets/responsive_scaffold.dart index b85c3ee..422fbfe 100644 --- a/app/lib/widgets/responsive_scaffold.dart +++ b/app/lib/widgets/responsive_scaffold.dart @@ -83,31 +83,7 @@ class _ResponsiveScaffoldState extends State { } } -// ===== 导航项定义(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 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!), + ), + ], + ), ), ); }