// 首页·日记流 — 严格对齐 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 '../../../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});
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => HomeBloc(
journalRepository: context.read(),
)..add(const HomeLoadData()),
child: const _HomeView(),
);
}
}
class _HomeView extends StatelessWidget {
const _HomeView();
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
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(
backgroundColor: bg,
body: isLoading
? const Center(child: CircularProgressIndicator())
: RefreshIndicator(
onRefresh: () async {
context.read().add(const HomeRefresh());
},
child: _buildContent(context, loaded),
),
);
},
);
}
Widget _buildContent(BuildContext context, HomeLoaded state) {
final now = DateTime.now();
final greeting = _greeting(now.hour);
final dateText = _formatDate(now);
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),
if (state.streakDays > 0) ...[
_StreakBadge(days: state.streakDays),
const SizedBox(height: DesignTokens.spacing16),
],
_MoodSelectorCard(
topMood: state.topMood,
todayWeather: state.todayWeather,
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';
}
}
/// 1. 顶部问候 + 日期 + 搜索按钮
class _GreetingHeader extends StatelessWidget {
const _GreetingHeader({
required this.greeting,
required this.username,
required this.dateText,
required this.onSearchTap,
});
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 fg = colorScheme.onSurface;
final muted = colorScheme.onSurfaceVariant;
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),
),
],
),
),
],
),
),
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.todayWeather,
required this.onMoodTap,
});
final Mood? topMood;
final Weather? todayWeather;
final ValueChanged onMoodTap;
static const _moods = [
('😊', '开心', Mood.happy),
('😐', '平静', Mood.calm),
('😢', '难过', Mood.sad),
('😡', '生气', Mood.angry),
('🤔', '思考', Mood.thinking),
];
static const _weatherMap = {
Weather.sunny: ('☀', '晴'),
Weather.cloudy: ('☁', '多云'),
Weather.rainy: ('🌧', '雨'),
Weather.snowy: ('❄', '雪'),
Weather.windy: ('💨', '风'),
};
@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(),
Builder(builder: (context) {
final w = _weatherMap[todayWeather] ?? _weatherMap[Weather.sunny]!;
return Text(
'${w.$1} ${w.$2}',
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,
),
),
],
);
}
}
/// 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),
),
),
),
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),
),
),
const SizedBox(height: 8),
Text(
hasTodayEntry ? '继续今天的记录' : '写点什么吧...',
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.w700,
color: AppColors.bgLight,
),
),
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) {
return Column(
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.contentExcerpt?.isNotEmpty == true
? journal.contentExcerpt!
: (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)),
),
],
),
),
),
);
}
}
class _EmptyJournalState extends StatelessWidget {
const _EmptyJournalState();
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Center(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: DesignTokens.spacing40),
child: Column(
children: [
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),
),
),
],
),
),
);
}
}