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

681 lines
20 KiB
Dart
Raw Permalink 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 (e) {
debugPrint('WeeklyPage._loadWeekData 失败: $e');
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)),
),
],
],
),
);
}
}