- SearchPage: 热搜词从日记标签频率动态生成 + 模板搜索网格 - ProfilePage: 成就徽章从 AchievementBloc 动态加载 + 头像首字母 - TeacherPage: 班级码改为对话框展示 (班级名+码+人数) - StickerLibraryPage: 分类从 API 动态合并 + 精选包卡片动态化 - TemplateGalleryPage: 适配动态数据 - ClassPage: 微调 - HomePage: 路由适配 - CalendarBloc: 新增测试 - AppRouter: 路由更新
914 lines
28 KiB
Dart
914 lines
28 KiB
Dart
// 首页·日记流 — 严格对齐 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 '../../../data/repositories/class_repository.dart';
|
||
import '../../../data/services/sync_engine.dart';
|
||
import '../../auth/bloc/auth_bloc.dart';
|
||
import '../../editor/widgets/share_bottom_sheet.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<JournalRepository>(),
|
||
)..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<HomeBloc, HomeState>(
|
||
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<HomeBloc>().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: context.select<AuthBloc, String>((bloc) {
|
||
final s = bloc.state;
|
||
return s is Authenticated ? s.user.displayLabel : '同学';
|
||
}),
|
||
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<Mood> 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<JournalEntry> 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: [
|
||
Row(
|
||
children: [
|
||
Text(
|
||
'${journal.date.month}月${journal.date.day}日',
|
||
style: TextStyle(fontSize: 11, color: theme.colorScheme.onSurfaceVariant),
|
||
),
|
||
const SizedBox(width: 6),
|
||
// 可见性标签
|
||
_VisibilityBadge(
|
||
isPrivate: journal.isPrivate,
|
||
sharedToClass: journal.sharedToClass,
|
||
onTap: journal.isPrivate
|
||
? () => _sharePrivateJournal(context, journal)
|
||
: null,
|
||
),
|
||
],
|
||
),
|
||
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)),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
/// 分享私密日记 — 弹出分享面板,将日记变为公开并上传到后端
|
||
Future<void> _sharePrivateJournal(BuildContext context, JournalEntry entry) async {
|
||
String? userClassId;
|
||
String userClassName = '我的班级';
|
||
|
||
try {
|
||
final authState = context.read<AuthBloc>().state;
|
||
if (authState is Authenticated) {
|
||
try {
|
||
final classRepo = context.read<ClassRepository>();
|
||
final classes = await classRepo.getMyClasses();
|
||
if (classes.isNotEmpty) {
|
||
userClassId = classes.first.id;
|
||
userClassName = classes.first.name;
|
||
}
|
||
} catch (_) {
|
||
// 没有班级信息,使用默认值
|
||
}
|
||
}
|
||
} catch (_) {}
|
||
|
||
// ignore: use_build_context_synchronously
|
||
showModalBottomSheet(
|
||
context: context,
|
||
isScrollControlled: true,
|
||
builder: (sheetContext) => ShareBottomSheet(
|
||
classId: userClassId,
|
||
className: userClassName,
|
||
onDecision: (shareToClass) async {
|
||
try {
|
||
final repo = context.read<JournalRepository>();
|
||
// 将私密日记变为公开
|
||
final updated = entry.copyWith(
|
||
isPrivate: false,
|
||
sharedToClass: shareToClass,
|
||
);
|
||
await repo.updateJournal(updated);
|
||
|
||
// 首次从私密变为公开 → 入队 SyncEngine 上传到后端
|
||
final syncEngine = context.read<SyncEngine>();
|
||
syncEngine.enqueue(PendingOperation(
|
||
id: updated.id,
|
||
type: SyncOperationType.create,
|
||
endpoint: '/diary/journals',
|
||
data: updated.toJson(),
|
||
version: updated.version,
|
||
createdAt: DateTime.now(),
|
||
));
|
||
|
||
// 刷新首页列表
|
||
// ignore: use_build_context_synchronously
|
||
context.read<HomeBloc>().add(const HomeRefresh());
|
||
} catch (e) {
|
||
debugPrint('分享日记失败: $e');
|
||
}
|
||
},
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 可见性标签 — 显示日记的可见性状态
|
||
///
|
||
/// - 私密:🔒 仅自己可见(可点击分享)
|
||
/// - 分享到班级:🏫 班级可见
|
||
/// - 公开:🌐 所有人可见
|
||
class _VisibilityBadge extends StatelessWidget {
|
||
const _VisibilityBadge({
|
||
required this.isPrivate,
|
||
required this.sharedToClass,
|
||
this.onTap,
|
||
});
|
||
|
||
final bool isPrivate;
|
||
final bool sharedToClass;
|
||
final VoidCallback? onTap;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
if (isPrivate) {
|
||
// 私密日记 — 显示锁定图标,可点击分享
|
||
return InkWell(
|
||
onTap: onTap,
|
||
customBorder: const StadiumBorder(),
|
||
child: Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||
decoration: BoxDecoration(
|
||
color: AppColors.tertiarySoftLight,
|
||
borderRadius: AppRadius.pillBorder,
|
||
),
|
||
child: const Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Icon(Icons.lock_outline, size: 12, color: Color(0xFFB8860B)),
|
||
SizedBox(width: 3),
|
||
Text(
|
||
'仅自己可见',
|
||
style: TextStyle(fontSize: 10, fontWeight: FontWeight.w500, color: Color(0xFFB8860B)),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
if (sharedToClass) {
|
||
return Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||
decoration: BoxDecoration(
|
||
color: AppColors.secondary.withValues(alpha: 0.15),
|
||
borderRadius: AppRadius.pillBorder,
|
||
),
|
||
child: const Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Icon(Icons.groups, size: 12, color: AppColors.secondary),
|
||
SizedBox(width: 3),
|
||
Text(
|
||
'班级可见',
|
||
style: TextStyle(fontSize: 10, fontWeight: FontWeight.w500, color: AppColors.secondary),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
return Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||
decoration: BoxDecoration(
|
||
color: AppColors.accent.withValues(alpha: 0.12),
|
||
borderRadius: AppRadius.pillBorder,
|
||
),
|
||
child: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Icon(Icons.public, size: 12, color: AppColors.accent),
|
||
const SizedBox(width: 3),
|
||
Text(
|
||
'公开',
|
||
style: TextStyle(fontSize: 10, fontWeight: FontWeight.w500, color: AppColors.accent),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
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),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|