Files
nj/app/lib/features/calendar/views/monthly_page.dart
iven 7e928ae1e1
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
fix(app): 修复 P2~P4 共 10 项前端问题
P2 必须修复:
- 教师布置主题 classId 从硬编码改为班级下拉选择器
- 班级日记墙使用服务端 classId 过滤替代前端过滤
- Profile 统计栏接入 JournalRepository 真实数据
- WeeklyPage 从全硬编码改为 JournalRepository 数据驱动

P3 建议改进:
- 提取 mood_utils.dart 公共函数,消除 4 处重复定义
- 贴纸库搜索框连接 StickerBloc 按名称过滤

P4 细节打磨:
- 家长页多孩子时显示 DropdownButton 选择器
- 搜索结果日记卡片点击跳转 /editor?id=
- MonthlyPage 照片数量从 JournalElement 统计
- calendar_page/mood_page/search_page 统一使用 moodToEmoji/moodToLabel
2026-06-02 20:21:51 +08:00

690 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 (_) {
// 单个日记加载元素失败不影响整体统计
}
}
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,
),
),
),
],
),
);
}
}