- 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: 添加全链路审计报告和验证截图
681 lines
20 KiB
Dart
681 lines
20 KiB
Dart
// 周概览页面 — 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)),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|