前端修复: - 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个页面全部渲染正常
635 lines
16 KiB
Dart
635 lines
16 KiB
Dart
// 月度概览页面 — 心情色彩月历 + 月度统计 + 精选日记
|
|
// 对齐 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,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|