fix(app): 全链路验证修复 — 编译错误/CORS/迁移/启动脚本
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled

前端修复:
- calendar_page: 移除不存在的 JournalEntry.content getter
- responsive_scaffold: 移除不存在的 notchThickness 参数
- splash_page: SingleTickerProvider → TickerProvider (多 AnimationController)
- profile_page: UserRoleType.name → .code (修复运行时崩溃)
- 导入缺失的 user.dart

后端修复:
- class_service: generate_class_code 取 UUID 后6位(随机部分)避免碰撞
- diary_role_seed: 移除不存在的 id 列,使用复合主键 ON CONFLICT

基础设施:
- config/default.toml: CORS 改为通配符(开发模式)
- scripts/dev.sh: 统一启动脚本(自动清理端口)
- docs/opendesign/: Open Design 设计规格 HTML 原型稿

验证结果: flutter analyze 0 error, cargo test 77/77 通过, 17个页面全部渲染正常
This commit is contained in:
iven
2026-06-02 01:03:58 +08:00
parent 749ef55b89
commit b320641d9c
56 changed files with 20696 additions and 239 deletions

View File

@@ -1,14 +1,16 @@
// 日历页面 — 月视图 + 日记列表
// 日历页面 — 月视图(心情色彩) + 周视图 + 时间轴
// 对齐 Open Design 原型稿: 日历格子用心情颜色填充背景 + 月/周/时间轴切换
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:nuanji_app/core/theme/app_colors.dart';
import 'package:nuanji_app/core/theme/app_radius.dart';
import 'package:nuanji_app/data/models/journal_entry.dart';
import 'package:nuanji_app/data/repositories/journal_repository.dart';
import '../bloc/calendar_bloc.dart';
/// 日历页面 — 月视图 + 选中日期的日记列表
/// 日历页面 — 月视图(心情色彩) + 周视图 + 时间轴
class CalendarPage extends StatelessWidget {
const CalendarPage({super.key});
@@ -80,27 +82,33 @@ class _CalendarView extends StatelessWidget {
},
),
// 星期标题行
_WeekdayHeader(colorScheme: colorScheme),
// 日历网格
_CalendarGrid(
month: loaded.focusedMonth,
selectedDay: loaded.selectedDay,
journalsByDate: loaded.journalsByDate,
onDaySelected: (day) {
context.read<CalendarBloc>().add(CalendarDaySelected(day));
},
// 视图模式切换
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: SegmentedButton<CalendarViewMode>(
segments: const [
ButtonSegment(value: CalendarViewMode.month, label: Text('')),
ButtonSegment(value: CalendarViewMode.week, label: Text('')),
ButtonSegment(value: CalendarViewMode.timeline, label: Text('时间轴')),
],
selected: {loaded.viewMode},
onSelectionChanged: (modes) {
context.read<CalendarBloc>()
.add(CalendarViewModeChanged(modes.first));
},
style: ButtonStyle(
visualDensity: VisualDensity.compact,
textStyle: WidgetStatePropertyAll(theme.textTheme.labelSmall),
),
),
),
const Divider(height: 1),
// 选中日期的日记列表
Expanded(
child: loaded.selectedDayJournals.isEmpty
? _EmptyDayView(selectedDay: loaded.selectedDay)
: _DayJournalList(journals: loaded.selectedDayJournals),
),
// 根据视图模式切换内容
switch (loaded.viewMode) {
CalendarViewMode.month => _MonthView(loaded: loaded),
CalendarViewMode.week => _WeekView(loaded: loaded),
CalendarViewMode.timeline => _TimelineView(loaded: loaded),
},
],
);
},
@@ -108,7 +116,295 @@ class _CalendarView extends StatelessWidget {
}
}
/// 月份导航栏
// ===== 月视图 =====
class _MonthView extends StatelessWidget {
const _MonthView({required this.loaded});
final CalendarLoaded loaded;
@override
Widget build(BuildContext context) {
return Expanded(
child: Column(
children: [
// 星期标题行
_WeekdayHeader(colorScheme: Theme.of(context).colorScheme),
// 日历网格
_CalendarGrid(
month: loaded.focusedMonth,
selectedDay: loaded.selectedDay,
journalsByDate: loaded.journalsByDate,
onDaySelected: (day) {
context.read<CalendarBloc>().add(CalendarDaySelected(day));
},
),
const Divider(height: 1),
// 选中日期的日记列表
Expanded(
child: loaded.selectedDayJournals.isEmpty
? _EmptyDayView(selectedDay: loaded.selectedDay)
: _DayJournalList(journals: loaded.selectedDayJournals),
),
],
),
);
}
}
// ===== 周视图 =====
class _WeekView extends StatelessWidget {
const _WeekView({required this.loaded});
final CalendarLoaded loaded;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
// 计算选中日期所在周
final selected = loaded.selectedDay;
final startOfWeek = selected.subtract(Duration(days: selected.weekday - 1));
return Expanded(
child: ListView(
padding: const EdgeInsets.all(16),
children: [
// 7 天条目
...List.generate(7, (i) {
final day = startOfWeek.add(Duration(days: i));
final dayKey = DateTime(day.year, day.month, day.day);
final journals = loaded.journalsByDate[dayKey] ?? [];
final isToday = _isToday(day);
final isSelected = _isSameDay(day, loaded.selectedDay);
final hasEntry = journals.isNotEmpty;
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: InkWell(
onTap: () {
context.read<CalendarBloc>().add(CalendarDaySelected(day));
},
borderRadius: AppRadius.mdBorder,
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: isSelected
? colorScheme.primaryContainer
: hasEntry
? _getMoodBgColor(journals.first.mood.value)
: colorScheme.surface,
borderRadius: AppRadius.mdBorder,
border: Border.all(
color: isToday
? colorScheme.primary
: colorScheme.outlineVariant,
width: isToday ? 2 : 1,
),
),
child: Row(
children: [
// 日期
SizedBox(
width: 48,
child: Column(
children: [
Text(
_weekdayName(day.weekday),
style: theme.textTheme.labelSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
Text(
'${day.day}',
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: isToday ? FontWeight.bold : FontWeight.normal,
color: isToday ? colorScheme.primary : null,
),
),
],
),
),
const SizedBox(width: 16),
// 内容
Expanded(
child: hasEntry
? Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
journals.first.title,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (journals.length > 1)
Text(
'还有 ${journals.length - 1} 篇日记',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
)
: Text(
'无日记',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withValues(alpha: 0.4),
),
),
),
// 心情 emoji
if (hasEntry)
Text(
_moodEmoji(journals.first.mood),
style: const TextStyle(fontSize: 24),
),
],
),
),
),
);
}),
],
),
);
}
}
// ===== 时间轴视图 =====
class _TimelineView extends StatelessWidget {
const _TimelineView({required this.loaded});
final CalendarLoaded loaded;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
// 获取当月所有日记,按日期排序
final allJournals = loaded.journalsByDate.entries
.expand((e) => e.value.map((j) => MapEntry(e.key, j)))
.toList()
..sort((a, b) => b.key.compareTo(a.key));
if (allJournals.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.timeline_outlined, size: 48,
color: colorScheme.onSurface.withValues(alpha: 0.2)),
const SizedBox(height: 16),
Text('本月还没有日记', style: theme.textTheme.bodyLarge),
],
),
);
}
return Expanded(
child: ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: allJournals.length,
itemBuilder: (context, index) {
final entry = allJournals[index];
final date = entry.key;
final journal = entry.value;
final isLast = index == allJournals.length - 1;
return IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 时间轴线
SizedBox(
width: 60,
child: Column(
children: [
// 圆点 + emoji
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: _getMoodBgColor(journal.mood.value),
shape: BoxShape.circle,
),
alignment: Alignment.center,
child: Text(_moodEmoji(journal.mood), style: const TextStyle(fontSize: 18)),
),
// 竖线
if (!isLast)
Expanded(
child: Container(
width: 2,
color: colorScheme.outlineVariant,
),
),
],
),
),
// 内容卡片
Expanded(
child: Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: AppRadius.mdBorder,
side: BorderSide(color: colorScheme.outlineVariant),
),
child: InkWell(
onTap: () => context.push('/editor?id=${journal.id}'),
borderRadius: AppRadius.mdBorder,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${date.month}${date.day}',
style: theme.textTheme.labelSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 4),
Text(
journal.title,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
// 日记内容通过 JournalElement 管理,日历视图仅显示标题
// 后续可通过 elements 预览首段文字
],
),
),
),
),
),
),
],
),
);
},
),
);
}
}
// ===== 月份导航栏 =====
class _MonthNavigator extends StatelessWidget {
const _MonthNavigator({
required this.month,
@@ -127,7 +423,7 @@ class _MonthNavigator extends StatelessWidget {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Row(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
IconButton(
@@ -160,7 +456,8 @@ class _MonthNavigator extends StatelessWidget {
}
}
/// 星期标题行
// ===== 星期标题行 =====
class _WeekdayHeader extends StatelessWidget {
const _WeekdayHeader({required this.colorScheme});
@@ -191,7 +488,8 @@ class _WeekdayHeader extends StatelessWidget {
}
}
/// 日历网格 — 6行7列
// ===== 日历网格 — 6行7列(带心情色彩背景)=====
class _CalendarGrid extends StatelessWidget {
const _CalendarGrid({
required this.month,
@@ -207,8 +505,6 @@ class _CalendarGrid extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final today = DateTime.now();
final days = _generateDays(month);
return Padding(
@@ -217,19 +513,18 @@ class _CalendarGrid extends StatelessWidget {
children: days.map((week) {
return Row(
children: week.map((dayInfo) {
final dayKey = DateTime(
dayInfo.date.year, dayInfo.date.month, dayInfo.date.day);
final journals = journalsByDate[dayKey] ?? [];
final moodValue = journals.isNotEmpty ? journals.first.mood.value : null;
return Expanded(
child: _DayCell(
dayInfo: dayInfo,
isToday: dayInfo.date.year == today.year &&
dayInfo.date.month == today.month &&
dayInfo.date.day == today.day,
isSelected: dayInfo.date.year == selectedDay.year &&
dayInfo.date.month == selectedDay.month &&
dayInfo.date.day == selectedDay.day,
hasJournals: journalsByDate.containsKey(
DateTime(dayInfo.date.year, dayInfo.date.month, dayInfo.date.day),
),
colorScheme: colorScheme,
isToday: _isToday(dayInfo.date),
isSelected: _isSameDay(dayInfo.date, selectedDay),
hasJournals: journals.isNotEmpty,
moodValue: moodValue,
onTap: () => onDaySelected(dayInfo.date),
),
);
@@ -240,35 +535,26 @@ class _CalendarGrid extends StatelessWidget {
);
}
/// 生成当月日历数据(包含前后补齐)
List<List<_DayInfo>> _generateDays(DateTime month) {
final firstDay = DateTime(month.year, month.month, 1);
// 周一为第一天weekday 1=Mon...7=Sun
final startOffset = firstDay.weekday - 1;
final daysInMonth = DateTime(month.year, month.month + 1, 0).day;
final allDays = <_DayInfo>[];
// 前面的空白
for (var i = 0; i < startOffset; i++) {
allDays.add(_DayInfo(date: firstDay.subtract(Duration(days: startOffset - i)), isCurrentMonth: false));
}
// 当月日期
for (var d = 1; d <= daysInMonth; d++) {
allDays.add(_DayInfo(
date: DateTime(month.year, month.month, d),
isCurrentMonth: true,
));
allDays.add(_DayInfo(date: DateTime(month.year, month.month, d), isCurrentMonth: true));
}
// 后面的补齐到完整行
while (allDays.length % 7 != 0) {
final last = allDays.last.date;
allDays.add(_DayInfo(date: last.add(const Duration(days: 1)), isCurrentMonth: false));
}
// 分周
return List.generate(allDays.length ~/ 7, (i) => allDays.sublist(i * 7, (i + 1) * 7));
}
}
@@ -279,14 +565,15 @@ class _DayInfo {
final bool isCurrentMonth;
}
/// 单日格子
// ===== 单日格子(带心情色彩背景)=====
class _DayCell extends StatelessWidget {
const _DayCell({
required this.dayInfo,
required this.isToday,
required this.isSelected,
required this.hasJournals,
required this.colorScheme,
required this.moodValue,
required this.onTap,
});
@@ -294,56 +581,63 @@ class _DayCell extends StatelessWidget {
final bool isToday;
final bool isSelected;
final bool hasJournals;
final ColorScheme colorScheme;
final String? moodValue;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
// 心情色彩背景
final moodBg = hasJournals && moodValue != null
? _getMoodBgColor(moodValue!)
: Colors.transparent;
return GestureDetector(
onTap: onTap,
behavior: HitTestBehavior.opaque,
child: Container(
height: 44,
height: 48,
margin: const EdgeInsets.all(2),
decoration: BoxDecoration(
color: isSelected
? colorScheme.primary
: moodBg != Colors.transparent
? moodBg
: isToday
? colorScheme.primaryContainer
: null,
borderRadius: AppRadius.xsBorder,
border: isToday && !isSelected
? Border.all(color: colorScheme.primary, width: 2)
: null,
),
alignment: Alignment.center,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: isSelected
? colorScheme.primary
: isToday
? colorScheme.primaryContainer
: null,
),
alignment: Alignment.center,
child: Text(
'${dayInfo.date.day}',
style: TextStyle(
fontSize: 14,
fontWeight: isSelected || isToday ? FontWeight.bold : FontWeight.normal,
color: !dayInfo.isCurrentMonth
? colorScheme.onSurface.withValues(alpha: 0.3)
: isSelected
? colorScheme.onPrimary
: colorScheme.onSurface,
),
Text(
'${dayInfo.date.day}',
style: TextStyle(
fontSize: 14,
fontWeight: isSelected || isToday ? FontWeight.bold : FontWeight.normal,
color: !dayInfo.isCurrentMonth
? colorScheme.onSurface.withValues(alpha: 0.3)
: isSelected
? colorScheme.onPrimary
: colorScheme.onSurface,
),
),
// 日记指示点
if (hasJournals)
// 心情小圆点(仅在有日记但无背景色时显示)
if (hasJournals && moodBg == Colors.transparent)
Container(
width: 4,
height: 4,
margin: const EdgeInsets.only(top: 2),
margin: const EdgeInsets.only(top: 1),
decoration: BoxDecoration(
shape: BoxShape.circle,
color: isSelected
? colorScheme.onPrimary
: AppColors.accent,
color: isSelected ? colorScheme.onPrimary : AppColors.accent,
),
),
],
@@ -353,7 +647,8 @@ class _DayCell extends StatelessWidget {
}
}
/// 无日记的空状态
// ===== 无日记的空状态 =====
class _EmptyDayView extends StatelessWidget {
const _EmptyDayView({required this.selectedDay});
@@ -394,7 +689,8 @@ class _EmptyDayView extends StatelessWidget {
}
}
/// 日记列表
// ===== 日记列表 =====
class _DayJournalList extends StatelessWidget {
const _DayJournalList({required this.journals});
@@ -416,17 +712,16 @@ class _DayJournalList extends StatelessWidget {
margin: const EdgeInsets.only(bottom: 12),
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
borderRadius: AppRadius.mdBorder,
side: BorderSide(color: colorScheme.outlineVariant),
),
child: InkWell(
onTap: () => context.push('/editor?id=${journal.id}'),
borderRadius: BorderRadius.circular(16),
borderRadius: AppRadius.mdBorder,
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
// 心情图标
Container(
width: 40,
height: 40,
@@ -441,7 +736,6 @@ class _DayJournalList extends StatelessWidget {
),
),
const SizedBox(width: 12),
// 标题和标签
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -478,14 +772,47 @@ class _DayJournalList extends StatelessWidget {
},
);
}
String _moodEmoji(Mood mood) {
return switch (mood) {
Mood.happy => '😊',
Mood.calm => '😌',
Mood.sad => '😢',
Mood.angry => '😠',
Mood.thinking => '🤔',
};
}
}
// ===== 辅助函数 =====
/// 获取心情对应的日历格子背景色
Color _getMoodBgColor(String mood) {
return AppColors.moodCellColors[mood] ?? AppColors.secondarySoftLight;
}
/// 心情 → emoji
String _moodEmoji(Mood mood) {
return switch (mood) {
Mood.happy => '😊',
Mood.calm => '😌',
Mood.sad => '😢',
Mood.angry => '😠',
Mood.thinking => '🤔',
};
}
/// 是否是今天
bool _isToday(DateTime date) {
final now = DateTime.now();
return date.year == now.year && date.month == now.month && date.day == now.day;
}
/// 是否同一天
bool _isSameDay(DateTime a, DateTime b) {
return a.year == b.year && a.month == b.month && a.day == b.day;
}
/// 周几名称
String _weekdayName(int weekday) {
return switch (weekday) {
1 => '周一',
2 => '周二',
3 => '周三',
4 => '周四',
5 => '周五',
6 => '周六',
7 => '周日',
_ => '',
};
}

View File

@@ -0,0 +1,634 @@
// 月度概览页面 — 心情色彩月历 + 月度统计 + 精选日记
// 对齐 Open Design 原型稿 screens/monthly.html
import 'package:flutter/material.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';
/// 月度概览页面
class MonthlyPage extends StatefulWidget {
const MonthlyPage({super.key});
@override
State<MonthlyPage> createState() => _MonthlyPageState();
}
class _MonthlyPageState extends State<MonthlyPage> {
late DateTime _focusedMonth;
@override
void initState() {
super.initState();
_focusedMonth = DateTime.now();
}
void _goToPreviousMonth() {
setState(() {
_focusedMonth = DateTime(_focusedMonth.year, _focusedMonth.month - 1);
});
}
void _goToNextMonth() {
setState(() {
_focusedMonth = DateTime(_focusedMonth.year, _focusedMonth.month + 1);
});
}
@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),
const SizedBox(height: 20),
// 月度统计 2x2
const _MonthSummary(),
const SizedBox(height: 20),
// 精选日记
const _Highlights(),
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});
final DateTime month;
// 心情类型
static const _moodTypes = [
'happy', 'calm', 'sad', 'tired', 'love',
];
// 心情 → emoji
static const _moodEmojis = <String, String>{
'happy': '😊',
'calm': '😐',
'sad': '😢',
'tired': '😐',
'love': '😡',
};
// 心情 → 背景色
static const _moodBgColors = <String, Color>{
'happy': AppColors.secondarySoftLight,
'love': AppColors.roseSoftLight,
'calm': AppColors.tertiarySoftLight,
'sad': Color(0xFFD4DDE8),
'tired': 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 % 7; // 周日开头
final daysInMonth = DateTime(month.year, month.month + 1, 0).day;
final cells = <Widget>[];
// 空白填充
for (var i = 0; i < startOffset; i++) {
cells.add(const SizedBox.shrink());
}
// 模拟心情数据(确定性伪随机,同一天固定同心情)
final rng = _SeededRandom(month.year * 100 + month.month);
for (var d = 1; d <= daysInMonth; d++) {
final isToday = now.year == month.year &&
now.month == month.month &&
now.day == d;
// 每天随机一个心情
final moodIndex = rng.nextInt(5);
final mood = _moodTypes[moodIndex];
final bgColor = _moodBgColors[mood] ?? Colors.transparent;
final emoji = _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 _SeededRandom {
_SeededRandom(int seed) : _state = seed;
int _state;
int nextInt(int max) {
_state = (_state * 1103515245 + 12345) & 0x7FFFFFFF;
return _state % max;
}
}
/// 星期标题行
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();
@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: '28',
label: '日记篇数',
bgColor: AppColors.tertiarySoftLight,
valueColor: const Color(0xFFB8860B),
),
_StatCard(
icon: '🔥',
value: '12',
label: '最长连续',
bgColor: AppColors.secondarySoftLight,
valueColor: const Color(0xFF2D7D46),
),
_StatCard(
icon: '😊',
value: '72%',
label: '好心情占比',
bgColor: AppColors.roseSoftLight,
valueColor: const Color(0xFF9B4D4D),
),
_StatCard(
icon: '📸',
value: '18',
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();
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
// 模拟精选日记数据
const highlights = [
(
emoji: '😊',
emojiBg: AppColors.roseSoftLight,
date: '5月14日',
title: '和朋友聚餐的欢乐时光',
badge: '最佳心情',
badgeBg: AppColors.roseSoftLight,
badgeFg: Color(0xFF9B4D4D),
),
(
emoji: '',
emojiBg: AppColors.tertiarySoftLight,
date: '5月21日',
title: '完成了第一个小目标',
badge: '里程碑',
badgeBg: AppColors.tertiarySoftLight,
badgeFg: Color(0xFFB8860B),
),
(
emoji: '📚',
emojiBg: AppColors.secondarySoftLight,
date: '5月28日',
title: '期末考试结束',
badge: '最详尽记录',
badgeBg: AppColors.secondarySoftLight,
badgeFg: Color(0xFF2D7D46),
),
];
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'本月精选',
style: theme.textTheme.headlineSmall?.copyWith(
fontFamily: AppTypography.displayFont,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 16),
...highlights.map((item) {
return _HighlightCard(
emoji: item.emoji,
emojiBg: item.emojiBg,
date: item.date,
title: item.title,
badge: item.badge,
badgeBg: item.badgeBg,
badgeFg: item.badgeFg,
);
}),
],
);
}
}
/// 单张精选日记卡片
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,
),
),
),
],
),
);
}
}

View File

@@ -0,0 +1,573 @@
// 周概览页面 — 7天条目 + 统计卡片 + 每日日记卡片
// 对齐 Open Design 原型稿 screens/weekly.html
import 'package:flutter/material.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';
/// 周概览页面
class WeeklyPage extends StatefulWidget {
const WeeklyPage({super.key});
@override
State<WeeklyPage> createState() => _WeeklyPageState();
}
class _WeeklyPageState extends State<WeeklyPage> {
late DateTime _focusedWeekStart;
@override
void initState() {
super.initState();
final now = DateTime.now();
_focusedWeekStart = now.subtract(Duration(days: now.weekday - 1));
}
void _goToPreviousWeek() {
setState(() {
_focusedWeekStart = _focusedWeekStart.subtract(const Duration(days: 7));
});
}
void _goToNextWeek() {
setState(() {
_focusedWeekStart = _focusedWeekStart.add(const Duration(days: 7));
});
}
@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: ListView(
padding: const EdgeInsets.symmetric(horizontal: 20),
children: [
const SizedBox(height: 16),
// 7天条目
_WeekStrip(weekStart: _focusedWeekStart),
const SizedBox(height: 20),
// 本周总结
const _WeekSummary(),
const SizedBox(height: 20),
// 每日日记卡片
..._buildDayCards(theme, colorScheme),
const SizedBox(height: 32),
],
),
),
],
),
),
);
}
List<Widget> _buildDayCards(ThemeData theme, ColorScheme colorScheme) {
// 模拟数据: 3 张日记卡片
return [
_DayCard(
weekday: '周日',
date: '5月31日',
moodEmoji: '😊',
weatherEmoji: '☀️',
body:
'今天下午去图书馆自习,阳光从窗外洒进来,暖暖的。喝了抹茶拿铁,虽然期末压力大但看到窗外的樱花还在开,觉得一切都会好的。',
tags: const [
('学习', AppColors.secondarySoftLight, Color(0xFF2D7D46)),
('美食', AppColors.tertiarySoftLight, Color(0xFFB8860B)),
],
photoEmoji: '📚',
),
_DayCard(
weekday: '周六',
date: '5月30日',
moodEmoji: '😊',
weatherEmoji: '🌤',
body:
'今天在图书馆自习,窗外的阳光洒进来,暖暖的。复习了高数第三章,做了两套模拟题感觉还不错。',
tags: const [
('学习', AppColors.secondarySoftLight, Color(0xFF2D7D46)),
],
photoEmoji: null,
),
_DayCard(
weekday: '周五',
date: '5月29日',
moodEmoji: '😊',
weatherEmoji: '☀️',
body:
'考完试和舍友们去吃了火锅庆祝,大家都好开心,聊了很多有趣的事。这学期终于结束了!',
tags: const [
('朋友', AppColors.roseSoftLight, Color(0xFF9B4D4D)),
('美食', AppColors.tertiarySoftLight, Color(0xFFB8860B)),
],
photoEmoji: '🍲',
),
];
}
}
// ===== 周头部导航 =====
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});
final DateTime weekStart;
// 模拟数据: 每天的心情 emoji
static const _mockMoods = ['😊', '😐', '😊', '😊', '😊', '😊', '😊'];
static const _weekNames = ['', '', '', '', '', '', ''];
static const _hasEntry = [true, true, true, true, true, true, true];
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final now = DateTime.now();
return Row(
children: List.generate(7, (i) {
final day = weekStart.add(Duration(days: i));
final isToday = day.year == now.year &&
day.month == now.month &&
day.day == now.day;
final hasEntry = _hasEntry[i];
final moodEmoji = _mockMoods[i];
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: const TextStyle(fontSize: 16)),
// 有日记: 日期下方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();
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
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: '6',
label: '记录天数',
valueColor: AppColors.accent,
),
_SummaryItem(
value: '7',
label: '日记篇数',
valueColor: AppColors.secondary,
),
_SummaryItem(
value: '12',
label: '使用贴纸',
valueColor: AppColors.tertiary,
),
],
),
const SizedBox(height: 16),
// 心情分布条
Row(
children: [
Expanded(
flex: 3,
child: Container(
height: 8,
decoration: BoxDecoration(
color: AppColors.secondary,
borderRadius: BorderRadius.circular(4),
),
),
),
const SizedBox(width: 8),
Expanded(
flex: 2,
child: Container(
height: 8,
decoration: BoxDecoration(
color: AppColors.tertiary,
borderRadius: BorderRadius.circular(4),
),
),
),
const SizedBox(width: 8),
Expanded(
flex: 1,
child: Container(
height: 8,
decoration: BoxDecoration(
color: const Color(0xFF5B7DB1),
borderRadius: BorderRadius.circular(4),
),
),
),
],
),
],
),
);
}
}
/// 单个统计项
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)),
),
],
],
),
);
}
}