Files
nj/app/lib/features/calendar/views/weekly_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

680 lines
20 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.
// 周概览页面 — 7天条目 + 统计卡片 + 每日日记卡片
// 对齐 Open Design 原型稿 screens/weekly.html
// 接入 JournalRepository 加载真实数据
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/core/utils/mood_utils.dart';
import 'package:nuanji_app/data/models/journal_entry.dart';
import 'package:nuanji_app/data/repositories/journal_repository.dart';
/// 周概览页面
class WeeklyPage extends StatefulWidget {
const WeeklyPage({super.key});
@override
State<WeeklyPage> createState() => _WeeklyPageState();
}
class _WeeklyPageState extends State<WeeklyPage> {
late DateTime _focusedWeekStart;
List<JournalEntry> _journals = [];
bool _isLoading = true;
@override
void initState() {
super.initState();
final now = DateTime.now();
_focusedWeekStart = _startOfWeek(now);
_loadWeekData();
}
JournalRepository get _repo => context.read<JournalRepository>();
/// 获取某天的周一日期
DateTime _startOfWeek(DateTime date) {
return date.subtract(Duration(days: date.weekday - 1));
}
Future<void> _loadWeekData() async {
if (!mounted) return;
setState(() => _isLoading = true);
try {
final weekEnd = _focusedWeekStart.add(const Duration(days: 7));
final journals = await _repo.getJournals(
dateFrom: _focusedWeekStart,
dateTo: weekEnd,
);
if (mounted) {
setState(() {
_journals = journals;
_isLoading = false;
});
}
} catch (_) {
if (mounted) setState(() => _isLoading = false);
}
}
void _goToPreviousWeek() {
setState(() {
_focusedWeekStart = _focusedWeekStart.subtract(const Duration(days: 7));
});
_loadWeekData();
}
void _goToNextWeek() {
setState(() {
_focusedWeekStart = _focusedWeekStart.add(const Duration(days: 7));
});
_loadWeekData();
}
/// 按日期索引日记: day-of-week (1=周一..7=周日) → JournalEntry 列表
Map<int, List<JournalEntry>> get _journalsByWeekday {
final map = <int, List<JournalEntry>>{};
for (final j in _journals) {
// 判断日记日期是否在本周范围内
final dayKey = j.date.difference(_focusedWeekStart).inDays;
if (dayKey >= 0 && dayKey < 7) {
final weekday = dayKey + 1; // 1=周一, 7=周日
(map[weekday] ??= []).add(j);
}
}
return map;
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Scaffold(
body: SafeArea(
child: Column(
children: [
// 周头部导航
_WeekHeader(
weekStart: _focusedWeekStart,
onPrevious: _goToPreviousWeek,
onNext: _goToNextWeek,
),
// 可滚动内容区
Expanded(
child: _isLoading
? const Center(child: CircularProgressIndicator())
: ListView(
padding: const EdgeInsets.symmetric(horizontal: 20),
children: [
const SizedBox(height: 16),
// 7天条目真实数据
_WeekStrip(
weekStart: _focusedWeekStart,
journalsByWeekday: _journalsByWeekday,
),
const SizedBox(height: 20),
// 本周总结(真实数据)
_WeekSummary(journals: _journals),
const SizedBox(height: 20),
// 每日日记卡片(真实数据)
..._buildDayCards(theme, colorScheme),
const SizedBox(height: 32),
],
),
),
],
),
),
);
}
List<Widget> _buildDayCards(ThemeData theme, ColorScheme colorScheme) {
final byWeekday = _journalsByWeekday;
final cards = <Widget>[];
final weekNames = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];
// 按日期倒序生成卡片(最新的在上面)
for (var i = 6; i >= 0; i--) {
final weekday = i + 1;
final dayJournals = byWeekday[weekday];
if (dayJournals == null || dayJournals.isEmpty) continue;
final day = _focusedWeekStart.add(Duration(days: i));
final first = dayJournals.first;
cards.add(_DayCard(
weekday: weekNames[i],
date: '${day.month}${day.day}',
moodEmoji: moodToEmoji(first.mood),
weatherEmoji: _weatherEmoji(first.weather),
body: first.contentExcerpt ?? first.title,
tags: first.tags.take(2).map((tag) {
// 根据标签内容选择颜色
return (tag, AppColors.secondarySoftLight, const Color(0xFF2D7D46));
}).toList(),
photoEmoji: dayJournals.any((j) => j.contentExcerpt != null && j.contentExcerpt!.contains('📷'))
? '📷'
: null,
));
}
// 无日记时显示空状态
if (cards.isEmpty) {
return [
SizedBox(
height: 200,
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.edit_note_rounded, size: 48,
color: colorScheme.onSurface.withValues(alpha: 0.2)),
const SizedBox(height: 12),
Text('这周还没有日记', style: theme.textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurface.withValues(alpha: 0.4),
)),
],
),
),
),
];
}
return cards;
}
String _weatherEmoji(Weather weather) => switch (weather) {
Weather.sunny => '☀️',
Weather.cloudy => '',
Weather.rainy => '🌧️',
Weather.snowy => '❄️',
Weather.windy => '💨',
};
}
// ===== 周头部导航 =====
class _WeekHeader extends StatelessWidget {
const _WeekHeader({
required this.weekStart,
required this.onPrevious,
required this.onNext,
});
final DateTime weekStart;
final VoidCallback onPrevious;
final VoidCallback onNext;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
// 格式化: "2026年6月 第1周"
final monthNames = [
'', '1月', '2月', '3月', '4月', '5月', '6月',
'7月', '8月', '9月', '10月', '11月', '12月',
];
final title =
'${weekStart.year}${monthNames[weekStart.month]}${_weekOfMonth(weekStart)}';
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,
),
],
),
);
}
/// 计算是当月第几周
int _weekOfMonth(DateTime date) {
final firstDay = DateTime(date.year, date.month, 1);
final offset = firstDay.weekday - 1;
return ((date.day + offset) / 7).ceil();
}
}
/// 圆形导航按钮 (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),
),
);
}
}
// ===== 7天条目真实数据=====
class _WeekStrip extends StatelessWidget {
const _WeekStrip({
required this.weekStart,
required this.journalsByWeekday,
});
final DateTime weekStart;
final Map<int, List<JournalEntry>> journalsByWeekday;
static const _weekNames = ['', '', '', '', '', '', ''];
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final now = DateTime.now();
return Row(
children: List.generate(7, (i) {
final day = weekStart.add(Duration(days: i));
final weekday = i + 1;
final isToday = day.year == now.year &&
day.month == now.month &&
day.day == now.day;
final dayJournals = journalsByWeekday[weekday] ?? [];
final hasEntry = dayJournals.isNotEmpty;
final moodEmoji = hasEntry ? moodToEmoji(dayJournals.first.mood) : '·';
return Expanded(
child: GestureDetector(
onTap: () {
// TODO: 选择某天后刷新下方日记卡片
},
child: Container(
padding: const EdgeInsets.symmetric(vertical: 12),
decoration: BoxDecoration(
color: isToday ? AppColors.accent : null,
borderRadius: AppRadius.mdBorder,
),
child: Column(
children: [
// 周名
Text(
_weekNames[i],
style: TextStyle(
fontSize: 11,
color: isToday
? const Color(0xFFFFF8F0).withValues(alpha: 0.85)
: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 4),
// 日期数字
Text(
'${day.day}',
style: TextStyle(
fontFamily: AppTypography.displayFont,
fontSize: 20,
fontWeight: FontWeight.w700,
color: isToday
? const Color(0xFFFFF8F0) // accent-on
: colorScheme.onSurface.withValues(alpha: 0.7),
),
),
const SizedBox(height: 4),
// 心情 emoji
Text(moodEmoji, style: TextStyle(
fontSize: hasEntry ? 16 : 14,
color: hasEntry ? null : colorScheme.onSurface.withValues(alpha: 0.2),
)),
// 有日记: 日期下方4px小圆点
if (hasEntry && !isToday)
Container(
width: 4,
height: 4,
margin: const EdgeInsets.only(top: 4),
decoration: BoxDecoration(
shape: BoxShape.circle,
color: AppColors.accent,
),
),
if (hasEntry && isToday)
Container(
width: 4,
height: 4,
margin: const EdgeInsets.only(top: 4),
decoration: const BoxDecoration(
shape: BoxShape.circle,
color: Color(0xFFFFF8F0),
),
),
],
),
),
),
);
}),
);
}
}
// ===== 本周总结卡片(真实数据)=====
class _WeekSummary extends StatelessWidget {
const _WeekSummary({required this.journals});
final List<JournalEntry> journals;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
// 统计真实数据
final recordDays = journals.map((j) => j.date.day).toSet().length;
final journalCount = journals.length;
// 统计贴纸元素 — 从日记标签中估算Phase 1 简化)
final stickerCount = journals.fold<int>(
0, (sum, j) => sum + j.tags.length,
);
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: colorScheme.surface,
borderRadius: AppRadius.mdBorder,
boxShadow: AppShadows.soft(context),
border: Border.all(color: colorScheme.outlineVariant),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'本周总结',
style: theme.textTheme.titleMedium?.copyWith(
fontFamily: AppTypography.displayFont,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 16),
// 3个统计数字
Row(
children: [
_SummaryItem(
value: '$recordDays',
label: '记录天数',
valueColor: AppColors.accent,
),
_SummaryItem(
value: '$journalCount',
label: '日记篇数',
valueColor: AppColors.secondary,
),
_SummaryItem(
value: '$stickerCount',
label: '使用标签',
valueColor: AppColors.tertiary,
),
],
),
const SizedBox(height: 16),
// 心情分布条
_MoodDistributionBar(journals: journals),
],
),
);
}
}
/// 心情分布条 — 从日记数据计算各心情占比
class _MoodDistributionBar extends StatelessWidget {
const _MoodDistributionBar({required this.journals});
final List<JournalEntry> journals;
static const _moodConfig = [
(Mood.happy, AppColors.secondary),
(Mood.calm, AppColors.tertiary),
(Mood.sad, Color(0xFF5B7DB1)),
(Mood.angry, AppColors.accent),
(Mood.thinking, Color(0xFF8B7E74)),
];
@override
Widget build(BuildContext context) {
if (journals.isEmpty) {
return Container(
height: 8,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.outlineVariant.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(4),
),
);
}
// 统计各心情数量
final counts = <Mood, int>{};
for (final j in journals) {
counts[j.mood] = (counts[j.mood] ?? 0) + 1;
}
return Row(
children: _moodConfig.where((c) => counts[c.$1] != null).map((config) {
final count = counts[config.$1]!;
return Expanded(
flex: count,
child: Container(
height: 8,
margin: const EdgeInsets.only(right: 2),
decoration: BoxDecoration(
color: config.$2,
borderRadius: BorderRadius.circular(4),
),
),
);
}).toList(),
);
}
}
/// 单个统计项
class _SummaryItem extends StatelessWidget {
const _SummaryItem({
required this.value,
required this.label,
required this.valueColor,
});
final String value;
final String label;
final Color valueColor;
@override
Widget build(BuildContext context) {
return Expanded(
child: Column(
children: [
Text(
value,
style: TextStyle(
fontFamily: AppTypography.displayFont,
fontSize: 24,
fontWeight: FontWeight.w700,
color: valueColor,
),
),
const SizedBox(height: 2),
Text(
label,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
);
}
}
// ===== 每日日记卡片 =====
class _DayCard extends StatelessWidget {
const _DayCard({
required this.weekday,
required this.date,
required this.moodEmoji,
required this.weatherEmoji,
required this.body,
required this.tags,
this.photoEmoji,
});
final String weekday;
final String date;
final String moodEmoji;
final String weatherEmoji;
final String body;
final List<(String, Color, Color)> tags; // (label, bg, fg)
final String? photoEmoji;
@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: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 头部: 日期 + 心情/weather emoji
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'$weekday · $date',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
color: colorScheme.onSurface.withValues(alpha: 0.7),
),
),
Text(
'$moodEmoji $weatherEmoji',
style: const TextStyle(fontSize: 13),
),
],
),
const SizedBox(height: 12),
// 正文预览 (3行截断)
Text(
body,
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
height: 1.6,
),
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
// 标签 pills
if (tags.isNotEmpty) ...[
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 4,
children: tags.map((tag) {
return Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 3,
),
decoration: BoxDecoration(
color: tag.$2,
borderRadius: AppRadius.pillBorder,
),
child: Text(
tag.$1,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
color: tag.$3,
),
),
);
}).toList(),
),
],
// 照片占位
if (photoEmoji != null) ...[
const SizedBox(height: 12),
Container(
width: double.infinity,
height: 80,
decoration: BoxDecoration(
borderRadius: AppRadius.smBorder,
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
colorScheme.surfaceContainerHighest
.withValues(alpha: 0.6),
colorScheme.outlineVariant.withValues(alpha: 0.3),
],
),
),
alignment: Alignment.center,
child: Text(photoEmoji!, style: const TextStyle(fontSize: 24)),
),
],
],
),
);
}
}