Files
nj/app/lib/features/calendar/views/monthly_page.dart
iven 49d4aa36a7
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
fix(app): Phase 1.1 紧急修复 — SyncEngine 接入 + authorId + catch 异常处理
- 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: 添加全链路审计报告和验证截图
2026-06-02 21:21:43 +08:00

691 lines
19 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 月度概览页面 — 心情色彩月历 + 月度统计 + 精选日记
// 对齐 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,
),
),
),
],
),
);
}
}