- feat(sync): SyncEngine 接入 EditorPage, 保存时 enqueue + 网络恢复自动 trySync - fix(editor): authorId 从 AuthBloc 获取, 替代硬编码 'local' - fix(bloc): class_bloc/calendar/profile/parent catch(_).全部改为 debugPrint - feat(editor): 编辑器工具栏拆分 (brush_panel/tag_panel/text_format_bar/dot_grid_painter) - feat(editor): EditorBloc 扩展 + EditorPage 增强 - feat(search): SearchBloc 扩展搜索功能 - feat(home): HomeBloc/HomePage 增强 - feat(auth): LoginPage 增强 - feat(templates): TemplateGalleryPage 重构 - fix(web): 管理端班级/日记页面修复 - fix(server): comment_service + theme_handler 修复 - docs: 添加全链路审计报告和验证截图
691 lines
19 KiB
Dart
691 lines
19 KiB
Dart
// 月度概览页面 — 心情色彩月历 + 月度统计 + 精选日记
|
||
// 对齐 Open Design 原型稿 screens/monthly.html
|
||
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter_bloc/flutter_bloc.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_shadows.dart';
|
||
import 'package:nuanji_app/core/theme/app_typography.dart';
|
||
import 'package:nuanji_app/data/models/journal_entry.dart';
|
||
import 'package:nuanji_app/data/models/journal_element.dart';
|
||
import 'package:nuanji_app/data/repositories/journal_repository.dart';
|
||
|
||
/// 月度概览页面
|
||
class MonthlyPage extends StatefulWidget {
|
||
final JournalRepository? journalRepository;
|
||
const MonthlyPage({super.key, this.journalRepository});
|
||
|
||
@override
|
||
State<MonthlyPage> createState() => _MonthlyPageState();
|
||
}
|
||
|
||
class _MonthlyPageState extends State<MonthlyPage> {
|
||
late DateTime _focusedMonth;
|
||
List<JournalEntry> _journals = [];
|
||
int _photoCount = 0;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_focusedMonth = DateTime.now();
|
||
_loadJournals();
|
||
}
|
||
|
||
JournalRepository get _repo =>
|
||
widget.journalRepository ?? context.read<JournalRepository>();
|
||
|
||
Future<void> _loadJournals() async {
|
||
final firstDay = DateTime(_focusedMonth.year, _focusedMonth.month, 1);
|
||
// 下月 1 号作为上界(开区间),所以用 month+1
|
||
final nextMonth = DateTime(_focusedMonth.year, _focusedMonth.month + 1, 1);
|
||
final journals = await _repo.getJournals(
|
||
dateFrom: firstDay,
|
||
dateTo: nextMonth,
|
||
);
|
||
|
||
// 统计照片元素数量
|
||
var photoCount = 0;
|
||
for (final journal in journals) {
|
||
try {
|
||
final elements = await _repo.getElements(journal.id);
|
||
photoCount += elements.where((e) => e.elementType == ElementType.image).length;
|
||
} catch (e) {
|
||
debugPrint('MonthlyPage: 加载日记 ${journal.id} 元素失败: $e');
|
||
// 单个日记加载元素失败不影响整体统计
|
||
}
|
||
}
|
||
|
||
if (mounted) {
|
||
setState(() {
|
||
_journals = journals;
|
||
_photoCount = photoCount;
|
||
});
|
||
}
|
||
}
|
||
|
||
void _goToPreviousMonth() {
|
||
setState(() {
|
||
_focusedMonth = DateTime(_focusedMonth.year, _focusedMonth.month - 1);
|
||
});
|
||
_loadJournals();
|
||
}
|
||
|
||
void _goToNextMonth() {
|
||
setState(() {
|
||
_focusedMonth = DateTime(_focusedMonth.year, _focusedMonth.month + 1);
|
||
});
|
||
_loadJournals();
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Scaffold(
|
||
body: SafeArea(
|
||
child: Column(
|
||
children: [
|
||
// 月头部导航
|
||
_MonthHeader(
|
||
month: _focusedMonth,
|
||
onPrevious: _goToPreviousMonth,
|
||
onNext: _goToNextMonth,
|
||
),
|
||
// 可滚动内容区
|
||
Expanded(
|
||
child: ListView(
|
||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||
children: [
|
||
const SizedBox(height: 16),
|
||
// 心情色彩月历
|
||
_MoodCalendar(month: _focusedMonth, journals: _journals),
|
||
const SizedBox(height: 20),
|
||
// 月度统计 2x2
|
||
_MonthSummary(journals: _journals, photoCount: _photoCount),
|
||
const SizedBox(height: 20),
|
||
// 精选日记
|
||
_Highlights(journals: _journals),
|
||
const SizedBox(height: 32),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
// ===== 月头部导航 =====
|
||
|
||
class _MonthHeader extends StatelessWidget {
|
||
const _MonthHeader({
|
||
required this.month,
|
||
required this.onPrevious,
|
||
required this.onNext,
|
||
});
|
||
|
||
final DateTime month;
|
||
final VoidCallback onPrevious;
|
||
final VoidCallback onNext;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
final colorScheme = theme.colorScheme;
|
||
final title = '${month.year}年${month.month}月';
|
||
|
||
return Padding(
|
||
padding: const EdgeInsets.fromLTRB(20, 8, 12, 0),
|
||
child: Row(
|
||
children: [
|
||
Expanded(
|
||
child: Text(
|
||
title,
|
||
style: theme.textTheme.headlineSmall?.copyWith(
|
||
fontFamily: AppTypography.displayFont,
|
||
fontWeight: FontWeight.w700,
|
||
),
|
||
),
|
||
),
|
||
_NavButton(
|
||
icon: Icons.chevron_left_rounded,
|
||
onTap: onPrevious,
|
||
borderColor: colorScheme.outline,
|
||
foregroundColor: colorScheme.onSurface,
|
||
),
|
||
const SizedBox(width: 8),
|
||
_NavButton(
|
||
icon: Icons.chevron_right_rounded,
|
||
onTap: onNext,
|
||
borderColor: colorScheme.outline,
|
||
foregroundColor: colorScheme.onSurface,
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 圆形导航按钮 (44px 触摸目标)
|
||
class _NavButton extends StatelessWidget {
|
||
const _NavButton({
|
||
required this.icon,
|
||
required this.onTap,
|
||
required this.borderColor,
|
||
required this.foregroundColor,
|
||
});
|
||
|
||
final IconData icon;
|
||
final VoidCallback onTap;
|
||
final Color borderColor;
|
||
final Color foregroundColor;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return SizedBox(
|
||
width: 44,
|
||
height: 44,
|
||
child: OutlinedButton(
|
||
onPressed: onTap,
|
||
style: OutlinedButton.styleFrom(
|
||
padding: EdgeInsets.zero,
|
||
shape: const CircleBorder(),
|
||
side: BorderSide(color: borderColor, width: 1.5),
|
||
foregroundColor: foregroundColor,
|
||
backgroundColor: Theme.of(context).colorScheme.surface,
|
||
),
|
||
child: Icon(icon, size: 18),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
// ===== 心情色彩月历 =====
|
||
|
||
class _MoodCalendar extends StatelessWidget {
|
||
const _MoodCalendar({required this.month, required this.journals});
|
||
|
||
final DateTime month;
|
||
final List<JournalEntry> journals;
|
||
|
||
// 心情 → emoji(对齐 Mood 枚举: happy/calm/sad/angry/thinking)
|
||
static const _moodEmojis = <Mood, String>{
|
||
Mood.happy: '😊',
|
||
Mood.calm: '😌',
|
||
Mood.sad: '😢',
|
||
Mood.angry: '😡',
|
||
Mood.thinking: '🤔',
|
||
};
|
||
|
||
// 心情 → 背景色
|
||
static const _moodBgColors = <Mood, Color>{
|
||
Mood.happy: AppColors.secondarySoftLight,
|
||
Mood.angry: AppColors.roseSoftLight,
|
||
Mood.calm: AppColors.tertiarySoftLight,
|
||
Mood.sad: Color(0xFFD4DDE8),
|
||
Mood.thinking: Color(0xFFE8E4E0),
|
||
};
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
final colorScheme = theme.colorScheme;
|
||
final now = DateTime.now();
|
||
|
||
return Container(
|
||
padding: const EdgeInsets.all(16),
|
||
decoration: BoxDecoration(
|
||
color: colorScheme.surface,
|
||
borderRadius: AppRadius.mdBorder,
|
||
boxShadow: AppShadows.soft(context),
|
||
border: Border.all(color: colorScheme.outlineVariant),
|
||
),
|
||
child: Column(
|
||
children: [
|
||
// 星期标题行
|
||
_WeekdayRow(colorScheme: colorScheme),
|
||
const SizedBox(height: 8),
|
||
// 7列网格
|
||
_buildGrid(context, now),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildGrid(BuildContext context, DateTime now) {
|
||
final firstDay = DateTime(month.year, month.month, 1);
|
||
// 周一=0 → 偏移量; weekday 返回 1(周一)..7(周日)
|
||
final startOffset = firstDay.weekday - 1; // 周一开头
|
||
final daysInMonth = DateTime(month.year, month.month + 1, 0).day;
|
||
|
||
// 按日期建索引:day → JournalEntry
|
||
final journalByDay = <int, JournalEntry>{};
|
||
for (final j in journals) {
|
||
if (j.date.year == month.year && j.date.month == month.month) {
|
||
journalByDay[j.date.day] = j;
|
||
}
|
||
}
|
||
|
||
final cells = <Widget>[];
|
||
|
||
// 空白填充
|
||
for (var i = 0; i < startOffset; i++) {
|
||
cells.add(const SizedBox.shrink());
|
||
}
|
||
|
||
for (var d = 1; d <= daysInMonth; d++) {
|
||
final isToday = now.year == month.year &&
|
||
now.month == month.month &&
|
||
now.day == d;
|
||
|
||
final entry = journalByDay[d];
|
||
final mood = entry?.mood;
|
||
final bgColor =
|
||
mood != null ? (_moodBgColors[mood] ?? Colors.transparent) : Colors.transparent;
|
||
final emoji = mood != null ? (_moodEmojis[mood] ?? '') : '';
|
||
|
||
cells.add(
|
||
_MoodCell(
|
||
day: d,
|
||
emoji: emoji,
|
||
bgColor: bgColor,
|
||
isToday: isToday,
|
||
),
|
||
);
|
||
}
|
||
|
||
return GridView.count(
|
||
crossAxisCount: 7,
|
||
shrinkWrap: true,
|
||
physics: const NeverScrollableScrollPhysics(),
|
||
mainAxisSpacing: 3,
|
||
crossAxisSpacing: 3,
|
||
childAspectRatio: 1,
|
||
children: cells,
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 星期标题行
|
||
class _WeekdayRow extends StatelessWidget {
|
||
const _WeekdayRow({required this.colorScheme});
|
||
|
||
final ColorScheme colorScheme;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
const weekdays = ['一', '二', '三', '四', '五', '六', '日'];
|
||
return Row(
|
||
children: weekdays.map((day) {
|
||
return Expanded(
|
||
child: Center(
|
||
child: Text(
|
||
day,
|
||
style: TextStyle(
|
||
fontSize: 11,
|
||
fontWeight: FontWeight.w500,
|
||
color: colorScheme.onSurfaceVariant,
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}).toList(),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 单个心情格子
|
||
class _MoodCell extends StatelessWidget {
|
||
const _MoodCell({
|
||
required this.day,
|
||
required this.emoji,
|
||
required this.bgColor,
|
||
required this.isToday,
|
||
});
|
||
|
||
final int day;
|
||
final String emoji;
|
||
final Color bgColor;
|
||
final bool isToday;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final colorScheme = Theme.of(context).colorScheme;
|
||
|
||
return GestureDetector(
|
||
onTap: () {
|
||
// TODO: 选择日期,跳转详情
|
||
},
|
||
child: Container(
|
||
decoration: BoxDecoration(
|
||
color: bgColor,
|
||
borderRadius: BorderRadius.circular(6),
|
||
border: isToday
|
||
? Border.all(color: AppColors.accent, width: 2)
|
||
: null,
|
||
),
|
||
alignment: Alignment.center,
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Text(
|
||
'$day',
|
||
style: TextStyle(
|
||
fontSize: 12,
|
||
color: colorScheme.onSurface,
|
||
fontWeight: isToday ? FontWeight.w700 : FontWeight.w400,
|
||
),
|
||
),
|
||
const SizedBox(height: 1),
|
||
Text(
|
||
emoji,
|
||
style: const TextStyle(fontSize: 10),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
// ===== 月度统计 2x2 =====
|
||
|
||
class _MonthSummary extends StatelessWidget {
|
||
const _MonthSummary({required this.journals, required this.photoCount});
|
||
|
||
final List<JournalEntry> journals;
|
||
final int photoCount;
|
||
|
||
/// 计算最长连续写日记天数
|
||
int _calcLongestStreak() {
|
||
if (journals.isEmpty) return 0;
|
||
final days = journals.map((j) => j.date.day).toSet().toList()..sort();
|
||
int longest = 1;
|
||
int current = 1;
|
||
for (var i = 1; i < days.length; i++) {
|
||
if (days[i] == days[i - 1] + 1) {
|
||
current++;
|
||
if (current > longest) longest = current;
|
||
} else {
|
||
current = 1;
|
||
}
|
||
}
|
||
return longest;
|
||
}
|
||
|
||
/// 计算"好心情"(happy/calm)占比
|
||
String _calcGoodMoodPercent() {
|
||
if (journals.isEmpty) return '0%';
|
||
final good = journals.where(
|
||
(j) => j.mood == Mood.happy || j.mood == Mood.calm,
|
||
).length;
|
||
return '${((good / journals.length) * 100).round()}%';
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
'本月总结',
|
||
style: theme.textTheme.headlineSmall?.copyWith(
|
||
fontFamily: AppTypography.displayFont,
|
||
fontWeight: FontWeight.w700,
|
||
),
|
||
),
|
||
const SizedBox(height: 16),
|
||
GridView.count(
|
||
crossAxisCount: 2,
|
||
shrinkWrap: true,
|
||
physics: const NeverScrollableScrollPhysics(),
|
||
mainAxisSpacing: 12,
|
||
crossAxisSpacing: 12,
|
||
childAspectRatio: 1.2,
|
||
children: [
|
||
_StatCard(
|
||
icon: '📝',
|
||
value: '${journals.length}',
|
||
label: '日记篇数',
|
||
bgColor: AppColors.tertiarySoftLight,
|
||
valueColor: const Color(0xFFB8860B),
|
||
),
|
||
_StatCard(
|
||
icon: '🔥',
|
||
value: '${_calcLongestStreak()}',
|
||
label: '最长连续',
|
||
bgColor: AppColors.secondarySoftLight,
|
||
valueColor: const Color(0xFF2D7D46),
|
||
),
|
||
_StatCard(
|
||
icon: '😊',
|
||
value: _calcGoodMoodPercent(),
|
||
label: '好心情占比',
|
||
bgColor: AppColors.roseSoftLight,
|
||
valueColor: const Color(0xFF9B4D4D),
|
||
),
|
||
_StatCard(
|
||
icon: '📸',
|
||
value: '$photoCount',
|
||
label: '照片数量',
|
||
bgColor: const Color(0xFFD4DDE8),
|
||
valueColor: const Color(0xFF4A6B8A),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 单张统计小卡片
|
||
class _StatCard extends StatelessWidget {
|
||
const _StatCard({
|
||
required this.icon,
|
||
required this.value,
|
||
required this.label,
|
||
required this.bgColor,
|
||
required this.valueColor,
|
||
});
|
||
|
||
final String icon;
|
||
final String value;
|
||
final String label;
|
||
final Color bgColor;
|
||
final Color valueColor;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
|
||
return Container(
|
||
padding: const EdgeInsets.all(16),
|
||
decoration: BoxDecoration(
|
||
color: bgColor,
|
||
borderRadius: AppRadius.mdBorder,
|
||
),
|
||
child: Column(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [
|
||
Text(icon, style: const TextStyle(fontSize: 24)),
|
||
const SizedBox(height: 8),
|
||
Text(
|
||
value,
|
||
style: TextStyle(
|
||
fontFamily: AppTypography.displayFont,
|
||
fontSize: 24,
|
||
fontWeight: FontWeight.w700,
|
||
color: valueColor,
|
||
),
|
||
),
|
||
const SizedBox(height: 2),
|
||
Text(
|
||
label,
|
||
style: theme.textTheme.labelSmall?.copyWith(
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
// ===== 精选日记 =====
|
||
|
||
class _Highlights extends StatelessWidget {
|
||
const _Highlights({required this.journals});
|
||
|
||
final List<JournalEntry> journals;
|
||
|
||
static const _badgeConfig = <Mood, ({String badge, Color bg, Color fg})>{
|
||
Mood.happy: (badge: '最佳心情', bg: AppColors.roseSoftLight, fg: Color(0xFF9B4D4D)),
|
||
Mood.calm: (badge: '平静时光', bg: AppColors.tertiarySoftLight, fg: Color(0xFFB8860B)),
|
||
Mood.sad: (badge: '真实记录', bg: Color(0xFFD4DDE8), fg: Color(0xFF4A6B8A)),
|
||
Mood.angry: (badge: '真情流露', bg: AppColors.roseSoftLight, fg: Color(0xFF9B4D4D)),
|
||
Mood.thinking: (badge: '深度思考', bg: AppColors.secondarySoftLight, fg: Color(0xFF2D7D46)),
|
||
};
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
|
||
// 按日期降序取前 3 篇
|
||
final top = List<JournalEntry>.from(journals)
|
||
..sort((a, b) => b.date.compareTo(a.date));
|
||
final highlights = top.take(3).toList();
|
||
|
||
if (highlights.isEmpty) {
|
||
return const SizedBox.shrink();
|
||
}
|
||
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
'本月精选',
|
||
style: theme.textTheme.headlineSmall?.copyWith(
|
||
fontFamily: AppTypography.displayFont,
|
||
fontWeight: FontWeight.w700,
|
||
),
|
||
),
|
||
const SizedBox(height: 16),
|
||
...highlights.map((entry) {
|
||
final mood = entry.mood;
|
||
final emoji = _MoodCalendar._moodEmojis[mood] ?? '📝';
|
||
final emojiBg = _MoodCalendar._moodBgColors[mood] ?? AppColors.tertiarySoftLight;
|
||
final cfg = _badgeConfig[mood] ??
|
||
(badge: '日记', bg: AppColors.tertiarySoftLight, fg: const Color(0xFFB8860B));
|
||
final dateStr = '${entry.date.month}月${entry.date.day}日';
|
||
|
||
return _HighlightCard(
|
||
emoji: emoji,
|
||
emojiBg: emojiBg,
|
||
date: dateStr,
|
||
title: entry.title,
|
||
badge: cfg.badge,
|
||
badgeBg: cfg.bg,
|
||
badgeFg: cfg.fg,
|
||
);
|
||
}),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 单张精选日记卡片
|
||
class _HighlightCard extends StatelessWidget {
|
||
const _HighlightCard({
|
||
required this.emoji,
|
||
required this.emojiBg,
|
||
required this.date,
|
||
required this.title,
|
||
required this.badge,
|
||
required this.badgeBg,
|
||
required this.badgeFg,
|
||
});
|
||
|
||
final String emoji;
|
||
final Color emojiBg;
|
||
final String date;
|
||
final String title;
|
||
final String badge;
|
||
final Color badgeBg;
|
||
final Color badgeFg;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
final colorScheme = theme.colorScheme;
|
||
|
||
return Container(
|
||
margin: const EdgeInsets.only(bottom: 12),
|
||
padding: const EdgeInsets.all(16),
|
||
decoration: BoxDecoration(
|
||
color: colorScheme.surface,
|
||
borderRadius: AppRadius.mdBorder,
|
||
boxShadow: AppShadows.soft(context),
|
||
border: Border.all(color: colorScheme.outlineVariant),
|
||
),
|
||
child: Row(
|
||
children: [
|
||
// 48x48 emoji 圆圈
|
||
Container(
|
||
width: 48,
|
||
height: 48,
|
||
decoration: BoxDecoration(
|
||
color: emojiBg,
|
||
shape: BoxShape.circle,
|
||
),
|
||
alignment: Alignment.center,
|
||
child: Text(emoji, style: const TextStyle(fontSize: 24)),
|
||
),
|
||
const SizedBox(width: 12),
|
||
// 标题 + 日期
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
date,
|
||
style: theme.textTheme.labelSmall?.copyWith(
|
||
color: colorScheme.onSurfaceVariant,
|
||
),
|
||
),
|
||
const SizedBox(height: 2),
|
||
Text(
|
||
title,
|
||
style: theme.textTheme.titleMedium?.copyWith(
|
||
fontFamily: AppTypography.displayFont,
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
// badge pill
|
||
Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||
decoration: BoxDecoration(
|
||
color: badgeBg,
|
||
borderRadius: AppRadius.pillBorder,
|
||
),
|
||
child: Text(
|
||
badge,
|
||
style: TextStyle(
|
||
fontSize: 11,
|
||
fontWeight: FontWeight.w600,
|
||
color: badgeFg,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|